重构:机制简化

This commit is contained in:
taojiacheng committed 2026-07-21 16:08:07 +08:00
1 parent c462855d35
commit 8753ba26b4
52 files changed
+4933 -3374

No files matched your search

File diff suppressed because it is too large. Load diff
@@ -25,7 +25,7 @@ export function formatCompactActionState(state: ActionState): string {
export function formatActionStateTitle(state: ActionState): string {
if (state.type === 'ability_mode') {
return `${state.name};余${state.remainingActions}门神通${state.phase ? `;第${state.phase}式` : ''}。`;
return `${state.name};余${state.remainingActions}门神通。`;
}
const source = state.sourceAbility?.name ?? '战斗效果';
if (state.type === 'rest') {
@@ -18,7 +18,7 @@ export interface AbilityContext {
}
export interface AbilityCastSnapshot {
readonly variantId?: string;
readonly planId?: string;
readonly target: Unit;
readonly targetId: string;
readonly selectionProfile?: AbilitySelectionProfile;
@@ -93,7 +93,7 @@ export class Ability {
return this._baseDescription;
}
get runtimeVariantId(): string | undefined {
get runtimePlanId(): string | undefined {
return undefined;
}
@@ -281,7 +281,12 @@ export abstract class ActiveSkill extends Ability {
}
private resolveCosts(caster: Unit): ResourceCost[] {
return this.getCostConfigs(caster).map((cost) => {
return this.getCostConfigs(caster).filter((cost) =>
!cost.conditions?.length || checkConditions(
{ caster, target: caster, ability: this },
cost.conditions,
),
).map((cost) => {
if (cost.mode === 'flat') {
return {
type: cost.resource,
@@ -332,7 +337,7 @@ export abstract class ActiveSkill extends Ability {
const casterMp = context.caster.getCurrentMp();
const targetHp = context.target.getCurrentHp();
this._castSnapshot = Object.freeze({
variantId: this.runtimeVariantId,
planId: this.runtimePlanId,
target: context.target,
targetId: context.target.id,
selectionProfile: this.selectionProfile,
@@ -391,11 +396,6 @@ export abstract class ActiveSkill extends Ability {
casterHpRatioAfterCost: afterRatio,
casterMpBeforeCost: payment.beforeMp,
casterMpAfterCost: payment.afterMp,
targetHpBeforeEffects: target.getCurrentHp(),
targetHpRatioBeforeEffects:
target.getMaxHp() > 0
? target.getCurrentHp() / target.getMaxHp()
: 0,
});
EventBus.instance.publish<AbilityCostPaidEvent>({
type: 'AbilityCostPaidEvent',
@@ -1,141 +0,0 @@
import type {
AbilityConfig,
AbilityCostConfig,
AbilitySelectionProfile,
AbilityVariantConfig,
ConditionConfig,
} from '../core/configs';
import { checkConditions } from '../core/conditionEvaluator';
import { executeEffectConfigs } from '../core/effectExecutor';
import type { AbilityId } from '../core/types';
import type { Unit } from '../units/Unit';
import { ActiveSkill, type ActiveSkillConfig } from './ActiveSkill';
import type { AbilityContext } from './Ability';
import { TargetPolicy } from './TargetPolicy';
/** 运行时按单位状态解析完整技能形态,施法前冻结,结算后释放。 */
export class DynamicDataDrivenActiveSkill extends ActiveSkill {
private preparedVariant?: AbilityVariantConfig;
private readonly variantPolicies = new Map<string, TargetPolicy>();
constructor(
id: AbilityId,
name: string,
config: ActiveSkillConfig & {
effects?: AbilityConfig['effects'];
castEffects?: AbilityConfig['castEffects'];
variants: AbilityVariantConfig[];
},
) {
super(id, name, config);
this.baseCosts = config.costs?.map((cost) => ({ ...cost })) ?? [];
this.baseEffects = config.effects ?? [];
this.baseCastEffects = config.castEffects ?? [];
this.variants = [...config.variants].sort((a, b) => b.priority - a.priority);
for (const variant of this.variants) {
if (variant.targetPolicy) {
this.variantPolicies.set(variant.id, new TargetPolicy(variant.targetPolicy));
}
}
}
private readonly baseEffects: NonNullable<AbilityConfig['effects']>;
private readonly baseCastEffects: NonNullable<AbilityConfig['castEffects']>;
private readonly baseCosts: AbilityCostConfig[];
private readonly variants: AbilityVariantConfig[];
override get name(): string {
return this.currentVariant()?.name ?? super.name;
}
override get description(): string | undefined {
return this.currentVariant()?.description ?? super.description;
}
override get runtimeVariantId(): string | undefined {
return this.currentVariant()?.id;
}
override get targetPolicy(): TargetPolicy {
const variant = this.currentVariant();
return (variant && this.variantPolicies.get(variant.id)) ?? super.targetPolicy;
}
override get selectionProfile(): AbilitySelectionProfile | undefined {
return this.currentVariant()?.selectionProfile ?? super.selectionProfile;
}
override get castConditions(): ConditionConfig[] {
return this.currentVariant()?.castConditions ?? super.castConditions;
}
override prepareCast(context: AbilityContext): void {
this.preparedVariant = this.resolveVariant(context.caster, context.target);
super.prepareCast(context);
}
override cancelPreparedCast(): void {
this.preparedVariant = undefined;
super.cancelPreparedCast();
}
protected override getCostConfigs(caster: Unit): AbilityCostConfig[] {
const variant = this.preparedVariant ?? this.resolveVariant(caster, caster);
return variant?.costs ?? super.getCostConfigs(caster);
}
protected override executeSkill(caster: Unit, target: Unit): void {
const variant = this.preparedVariant ?? this.resolveVariant(caster, target);
executeEffectConfigs(variant?.effects ?? this.baseEffects, {
caster,
target,
ability: this,
castSnapshot: this.castSnapshot,
});
}
protected override executeCastEffects(caster: Unit, target: Unit): void {
const variant = this.preparedVariant ?? this.resolveVariant(caster, target);
executeEffectConfigs(
variant?.castEffects ?? this.baseCastEffects,
{ caster, target, ability: this, castSnapshot: this.castSnapshot },
);
}
protected override onCastFinished(): void {
this.preparedVariant = undefined;
}
override clone(): DynamicDataDrivenActiveSkill {
const cloned = new DynamicDataDrivenActiveSkill(this.id, super.name, {
description: super.description,
costs: this.baseCosts,
cooldown: this.maxCooldown,
priority: this.priority,
targetPolicy: super.targetPolicy,
selectionProfile: super.selectionProfile,
castConditions: super.castConditions,
effects: this.baseEffects,
castEffects: this.baseCastEffects,
variants: this.variants,
});
cloned.tags.addTags(this.tags.getTags());
if (this.currentCooldown > 0) cloned.modifyCooldown(this.currentCooldown);
return cloned;
}
private currentVariant(): AbilityVariantConfig | undefined {
if (this.preparedVariant) return this.preparedVariant;
const owner = this.getOwner();
return owner ? this.resolveVariant(owner, owner) : undefined;
}
private resolveVariant(caster: Unit, target: Unit): AbilityVariantConfig | undefined {
return this.variants.find((variant) =>
checkConditions(
{ caster, target, ability: this },
variant.conditions,
),
);
}
}
@@ -0,0 +1,131 @@
import type {
AbilityConfig,
AbilityCostConfig,
AbilityEffectLayerConfig,
AbilityEffectPlanConfig,
} from '../core/configs';
import {
resolveAbilityEffectPlan,
type ResolvedAbilityEffectPlan,
} from '../core/abilityEffectPlan';
import { executeEffectConfigs } from '../core/effectExecutor';
import { advanceAbilityMode } from '../core/runtimeState';
import type { AbilityId } from '../core/types';
import type { Unit } from '../units/Unit';
import { ActiveSkill, type ActiveSkillConfig } from './ActiveSkill';
import type { AbilityContext } from './Ability';
/** 固定目标、费用和 AI 意图,只允许按计划追加效果层的主动技能。 */
export class LayeredDataDrivenActiveSkill extends ActiveSkill {
private preparedPlan?: ResolvedAbilityEffectPlan;
private readonly baseCosts: AbilityCostConfig[];
private readonly baseName: string;
private readonly baseDescription?: string;
private readonly baseEffects: NonNullable<AbilityConfig['effects']>;
private readonly baseCompletionEffects: NonNullable<AbilityConfig['completionEffects']>;
private readonly baseCastEffects: NonNullable<AbilityConfig['castEffects']>;
private readonly effectLayers: AbilityEffectLayerConfig[];
private readonly effectPlans: AbilityEffectPlanConfig[];
constructor(
id: AbilityId,
name: string,
config: ActiveSkillConfig & {
effects?: AbilityConfig['effects'];
completionEffects?: AbilityConfig['completionEffects'];
castEffects?: AbilityConfig['castEffects'];
effectLayers?: AbilityEffectLayerConfig[];
effectPlans?: AbilityEffectPlanConfig[];
},
) {
super(id, name, config);
this.baseCosts = config.costs?.map((cost) => ({ ...cost })) ?? [];
this.baseName = name;
this.baseDescription = config.description;
this.baseEffects = config.effects ?? [];
this.baseCompletionEffects = config.completionEffects ?? [];
this.baseCastEffects = config.castEffects ?? [];
this.effectLayers = config.effectLayers ?? [];
this.effectPlans = config.effectPlans ?? [];
}
override get name(): string {
return this.currentPlan()?.name ?? this.baseName;
}
override get description(): string | undefined {
return this.currentPlan()?.description ?? this.baseDescription;
}
override get runtimePlanId(): string | undefined {
return this.currentPlan()?.id;
}
override prepareCast(context: AbilityContext): void {
this.preparedPlan = this.resolvePlan(context.caster, context.target);
super.prepareCast(context);
}
override cancelPreparedCast(): void {
this.preparedPlan = undefined;
super.cancelPreparedCast();
}
protected override executeSkill(caster: Unit, target: Unit): void {
const plan = this.preparedPlan ?? this.resolvePlan(caster, target);
const context = { caster, target, ability: this, castSnapshot: this.castSnapshot };
executeEffectConfigs(plan.effects, context);
executeEffectConfigs(plan.completionEffects, context);
if (plan.consumeModeKey) advanceAbilityMode(caster, plan.consumeModeKey);
}
protected override executeCastEffects(caster: Unit, target: Unit): void {
executeEffectConfigs(this.baseCastEffects, {
caster,
target,
ability: this,
castSnapshot: this.castSnapshot,
});
}
protected override onCastFinished(): void {
this.preparedPlan = undefined;
}
override clone(): LayeredDataDrivenActiveSkill {
const cloned = new LayeredDataDrivenActiveSkill(this.id, this.baseName, {
description: this.baseDescription,
costs: this.baseCosts,
cooldown: this.maxCooldown,
priority: this.priority,
targetPolicy: super.targetPolicy,
selectionProfile: super.selectionProfile,
castConditions: super.castConditions,
effects: this.baseEffects,
completionEffects: this.baseCompletionEffects,
castEffects: this.baseCastEffects,
effectLayers: this.effectLayers,
effectPlans: this.effectPlans,
});
cloned.tags.addTags(this.tags.getTags());
if (this.currentCooldown > 0) cloned.modifyCooldown(this.currentCooldown);
return cloned;
}
private currentPlan(): ResolvedAbilityEffectPlan | undefined {
if (this.preparedPlan) return this.preparedPlan;
const owner = this.getOwner();
return owner ? this.resolvePlan(owner, owner) : undefined;
}
private resolvePlan(caster: Unit, target: Unit): ResolvedAbilityEffectPlan {
return resolveAbilityEffectPlan({
name: this.baseName,
description: this.baseDescription,
effects: this.baseEffects,
completionEffects: this.baseCompletionEffects,
effectLayers: this.effectLayers,
effectPlans: this.effectPlans,
}, { caster, target, ability: this });
}
}
@@ -7,5 +7,5 @@ export {
type AbilitySelectionStrategy,
} from './AbilitySelectionStrategy';
export { ActiveSkill } from './ActiveSkill';
export { DynamicDataDrivenActiveSkill } from './DynamicDataDrivenActiveSkill';
export { LayeredDataDrivenActiveSkill } from './LayeredDataDrivenActiveSkill';
export { PassiveAbility } from './PassiveAbility';
@@ -266,7 +266,7 @@ describe('CultivatorCombatAdapter', () => {
baseline.realm_stage = '圆满';
baseline.sect = {
membershipId: 'wuxiang-member', sectId: 'wuxiang', status: 'active',
contribution: 0, configVersion: 1, activePathId: 'demon-crossing',
contribution: 0, configVersion: 2, activePathId: 'demon-crossing',
methods: {
'wuxiang-canon': 5, 'blood-lotus': 3, 'white-bone': 3,
'wrathful-ming': 3, 'six-senses': 3, 'reed-crossing-method': 3,
@@ -0,0 +1,116 @@
import type { Ability } from '../abilities/Ability';
import type { Unit } from '../units/Unit';
import type {
AbilityEffectLayerConfig,
AbilityEffectPlanConfig,
EffectConfig,
} from './configs';
import { checkConditions } from './conditionEvaluator';
export interface AbilityEffectPlanSource {
name: string;
description?: string;
effects?: EffectConfig[];
completionEffects?: EffectConfig[];
effectLayers?: AbilityEffectLayerConfig[];
effectPlans?: AbilityEffectPlanConfig[];
}
export interface AbilityEffectPlanContext {
caster: Unit;
target: Unit;
ability: Ability;
}
export interface ResolvedAbilityEffectPlan {
readonly id?: string;
readonly name: string;
readonly description?: string;
readonly effects: ReadonlyArray<EffectConfig>;
readonly completionEffects: ReadonlyArray<EffectConfig>;
readonly consumeModeKey?: string;
}
/** 在施法准备阶段解析一次;调用方负责持有返回快照直至本次结算结束。 */
export function resolveAbilityEffectPlan(
source: AbilityEffectPlanSource,
context: AbilityEffectPlanContext,
): ResolvedAbilityEffectPlan {
const plan = [...(source.effectPlans ?? [])]
.sort((left, right) => right.priority - left.priority)
.find((candidate) => checkConditions(context, candidate.conditions));
const layersById = new Map(
(source.effectLayers ?? []).map((layer) => [layer.id, layer] as const),
);
const selectedLayers = plan?.layerIds.map((id) => layersById.get(id)!) ?? [];
return Object.freeze({
id: plan?.id,
name: plan?.name ?? source.name,
description: plan?.description ?? source.description,
effects: Object.freeze([
...(source.effects ?? []),
...selectedLayers.flatMap((layer) => layer.effects ?? []),
]),
completionEffects: Object.freeze([
...(source.completionEffects ?? []),
...selectedLayers.flatMap((layer) => layer.completionEffects ?? []),
]),
consumeModeKey: plan?.consumeModeKey,
});
}
export function validateAbilityEffectPlans(source: {
slug: string;
effectLayers?: AbilityEffectLayerConfig[];
effectPlans?: AbilityEffectPlanConfig[];
}): void {
const layers = source.effectLayers ?? [];
const plans = source.effectPlans ?? [];
assertUniqueIds(source.slug, 'layer', layers.map((layer) => layer.id));
assertUniqueIds(source.slug, 'plan', plans.map((plan) => plan.id));
const layerIds = new Set(layers.map((layer) => layer.id));
const allowedPlanKeys = new Set([
'id',
'name',
'description',
'priority',
'conditions',
'layerIds',
'consumeModeKey',
]);
for (const plan of plans) {
const unsupportedKeys = Object.keys(plan).filter((key) => !allowedPlanKeys.has(key));
if (unsupportedKeys.length > 0) {
throw new Error(
`[AbilityFactory] ability ${source.slug} plan ${plan.id} cannot define ${unsupportedKeys.join(', ')}`,
);
}
assertUniqueIds(source.slug, `plan ${plan.id} layer reference`, plan.layerIds);
for (const layerId of plan.layerIds) {
if (!layerIds.has(layerId)) {
throw new Error(
`[AbilityFactory] ability ${source.slug} plan ${plan.id} references unknown layer ${layerId}`,
);
}
}
if (plan.consumeModeKey !== undefined && plan.consumeModeKey.trim() === '') {
throw new Error(
`[AbilityFactory] ability ${source.slug} plan ${plan.id} has an empty consumeModeKey`,
);
}
}
}
function assertUniqueIds(slug: string, kind: string, ids: string[]): void {
const seen = new Set<string>();
for (const id of ids) {
if (!id.trim()) {
throw new Error(`[AbilityFactory] ability ${slug} has an empty ${kind} id`);
}
if (seen.has(id)) {
throw new Error(`[AbilityFactory] ability ${slug} has duplicate ${kind} id ${id}`);
}
seen.add(id);
}
}
@@ -16,5 +16,4 @@ export interface ActionStateView {
ability?: ActionStateAbilityView;
interruptPolicy?: ActionInterruptPolicy;
hitPolicy?: ActionHitPolicy;
phase?: number;
}
@@ -281,21 +281,10 @@ export function evaluateCondition(
if (cond.params.mode === 'none') return mode === undefined;
return (
mode?.mode === cond.params.mode &&
(cond.params.phase === undefined || mode.phase === cond.params.phase)
(cond.params.remainingUses === undefined ||
mode.remainingUses === cond.params.remainingUses)
);
}
case 'ability_mode_ability_differs': {
if (!scopedUnit || !cond.params.key || !context.ability) return false;
const mode = readAbilityMode(scopedUnit, cond.params.key);
return !!mode?.firstAbilityId && mode.firstAbilityId !== context.ability.id;
}
case 'ability_variant_is': {
const ability = (() => {
const event = context.triggerEvent as { ability?: { runtimeVariantId?: string } } | undefined;
return event?.ability ?? context.ability;
})();
return ability?.runtimeVariantId === cond.params.variantId;
}
case 'ability_cost_crossed': {
const event = context.triggerEvent as {
type?: string;
+44 -42
View File
@@ -25,27 +25,42 @@ export interface AbilitySelectionRule {
}
export type AbilityCostConfig =
| { resource: 'mp' | 'hp'; mode: 'flat'; amount: number; retain?: number }
| {
resource: 'mp' | 'hp';
mode: 'flat';
amount: number;
retain?: number;
conditions?: ConditionConfig[];
}
| {
resource: 'hp';
mode: 'current_hp_ratio' | 'current_percent';
ratio: number;
minimum?: number;
retain?: number;
conditions?: ConditionConfig[];
};
export interface AbilityVariantConfig {
/**
* 技能的追加效果层。基础 effects/completionEffects 为 A,计划只能按 ID 追加层。
*/
export interface AbilityEffectLayerConfig {
id: string;
effects?: EffectConfig[];
completionEffects?: EffectConfig[];
}
/**
* 受限的运行时效果计划:只改变展示与启用层,不得覆盖目标、费用或 AI 意图。
*/
export interface AbilityEffectPlanConfig {
id: string;
name: string;
description?: string;
priority: number;
conditions: ConditionConfig[];
costs?: AbilityCostConfig[];
targetPolicy?: AbilityConfig['targetPolicy'];
selectionProfile?: AbilitySelectionProfile;
castConditions?: ConditionConfig[];
effects?: EffectConfig[];
castEffects?: EffectConfig[];
layerIds: string[];
consumeModeKey?: string;
}
export interface CombatResourceDefinition {
@@ -96,8 +111,6 @@ export interface ConditionConfig {
| 'combat_resource_below'
| 'runtime_counter_compare'
| 'ability_mode_is'
| 'ability_mode_ability_differs'
| 'ability_variant_is'
| 'ability_cost_crossed'
| 'combat_resource_change'
| 'chance'
@@ -121,8 +134,7 @@ export interface ConditionConfig {
resourceId?: string;
key?: string;
mode?: string;
phase?: number;
variantId?: string;
remainingUses?: number;
timing?: 'live' | 'cast';
operation?: 'add' | 'subtract' | 'set' | 'consume_all';
eventField?: 'requested' | 'applied' | 'overflow';
@@ -194,6 +206,14 @@ export interface ResourceDrainParams {
export interface DispelParams {
targetTag?: string;
maxCount?: number;
/** 缺省为 target;用于自净化而不改变技能目标策略。 */
recipient?: 'caster' | 'target';
/** 缺省不区分;positive=增益,negative=减益或控制。 */
status?: 'positive' | 'negative';
/** 至少成功移除一个状态后执行一次。 */
effects?: EffectConfig[];
/** 没有合法状态可移除时执行一次。 */
fallbackEffects?: EffectConfig[];
}
/**
@@ -297,9 +317,9 @@ export interface ConsumeStatusTriggerParams {
displayName?: string;
consume?: 'one' | 'all' | number;
effects: EffectConfig[];
/** 状态不存在时执行一次;用于显式降级,不改变消费语义。 */
fallbackEffects?: EffectConfig[];
scaleEffectsByLayer?: boolean;
/** 按实际消费层数缩放数值强度,次数、目标数和状态操作仍只执行一次。 */
scaleNumericEffectsByLayer?: boolean;
target?: 'caster' | 'target';
}
@@ -331,8 +351,7 @@ export interface DamageMemoryParams {
| 'shield'
| 'critical_taken'
| 'shield_break'
| 'shield_absorbed'
| 'ability_cost_paid';
| 'shield_absorbed';
ratio?: number;
releaseAs?:
'damage' | 'heal' | 'shield' | 'reflect' | 'counter' | 'follow_up';
@@ -341,10 +360,6 @@ export interface DamageMemoryParams {
maxStoredValue?: ScalableValue;
includeShieldAbsorbed?: boolean;
consume?: boolean;
/** 释放后仅消费这部分记忆;1为全部,0.5为保留一半。 */
consumeRatio?: number;
/** 单次释放的最终记忆量上限。 */
maxReleaseValue?: ScalableValue;
}
export interface BuffLayerModifyParams {
@@ -468,32 +483,15 @@ export interface AbilityModeParams {
key: string;
operation: 'set' | 'advance' | 'clear';
mode?: string;
phase?: number;
remainingUses?: number;
displayName?: string;
/** 形态清除时一并移除的状态。 */
cleanupBuffIds?: string[];
}
export interface StatusTransferParams {
operation: 'move' | 'remove';
from: 'caster' | 'target';
to?: 'caster' | 'target';
status: 'positive' | 'negative';
maxCount?: number;
effects?: EffectConfig[];
fallbackEffects?: EffectConfig[];
}
export interface LifestealParams {
ratio: number;
maxHpRatioPerAction: number;
variantIds?: string[];
}
export interface DamageCapParams {
maxHpRatio: number;
deferOverflowTurns?: number;
}
/**
@@ -568,9 +566,7 @@ export type EffectConfig = BaseEffectConfig &
| { type: 'element_history'; params: ElementHistoryParams }
| { type: 'effect_sequence'; params: EffectSequenceParams }
| { type: 'ability_mode'; params: AbilityModeParams }
| { type: 'status_transfer'; params: StatusTransferParams }
| { type: 'lifesteal'; params: LifestealParams }
| { type: 'damage_cap'; params: DamageCapParams }
| { type: 'percent_damage_modifier'; params: PercentDamageModifierParams }
| { type: 'death_prevent'; params: DeathPreventParams }
| { type: 'buff_immunity'; params: BuffImmunityParams }
@@ -736,14 +732,20 @@ export interface AbilityConfig {
selectionProfile?: AbilitySelectionProfile;
castConditions?: ConditionConfig[];
/** 按运行时状态选取的完整技能形态;高 priority 优先。 */
variants?: AbilityVariantConfig[];
/**
* 主动效果链 (主动技能执行时触发)
*/
effects?: EffectConfig[];
/** 所有主效果层结算完成后执行;合法 no-op 不阻止该阶段。 */
completionEffects?: EffectConfig[];
/** 只能由 effectPlans 追加的效果层。 */
effectLayers?: AbilityEffectLayerConfig[];
/** 按运行时条件选择一个计划;未命中时仅执行基础效果。 */
effectPlans?: AbilityEffectPlanConfig[];
/** 消耗和冷却结算后必定执行,不受本次命中判定影响。 */
castEffects?: EffectConfig[];
@@ -1,308 +0,0 @@
import type { BuffConfig, EffectConfig } from './configs';
import type { ScalableValue } from './ValueCalculator';
function scaleValue(value: ScalableValue, multiplier: number): ScalableValue {
return {
...value,
base: value.base === undefined ? undefined : value.base * multiplier,
coefficient: value.attribute
? (value.coefficient ?? 1) * multiplier
: value.coefficient,
targetMaxHpRatio: value.targetMaxHpRatio === undefined
? undefined
: value.targetMaxHpRatio * multiplier,
targetMaxMpRatio: value.targetMaxMpRatio === undefined
? undefined
: value.targetMaxMpRatio * multiplier,
};
}
function scaleBuff(config: BuffConfig, multiplier: number): BuffConfig {
return {
...config,
modifiers: config.modifiers?.map((modifier) => ({
...modifier,
value: modifier.value * multiplier,
})),
listeners: config.listeners?.map((listener) => ({
...listener,
effects: listener.effects.map((effect) =>
scaleEffectNumericStrength(effect, multiplier)),
})),
};
}
function assertNever(value: never): never {
throw new Error(`未处理的效果强度缩放类型: ${JSON.stringify(value)}`);
}
/**
* 只缩放效果的连续数值强度。持续时间、触发次数、目标数和状态操作等离散语义保持不变。
*/
export function scaleEffectNumericStrength(
effect: EffectConfig,
multiplier: number,
): EffectConfig {
if (multiplier === 1) return effect;
switch (effect.type) {
case 'damage':
return {
...effect,
params: {
...effect.params,
value: scaleValue(effect.params.value, multiplier),
dynamicScalars: effect.params.dynamicScalars?.map((scalar) => ({
...scalar,
coefficientCap: scalar.coefficientCap * multiplier,
})),
},
};
case 'heal':
case 'shield':
case 'mana_burn':
return {
...effect,
params: { ...effect.params, value: scaleValue(effect.params.value, multiplier) },
} as EffectConfig;
case 'apply_buff':
return {
...effect,
params: { ...effect.params, buffConfig: scaleBuff(effect.params.buffConfig, multiplier) },
};
case 'resource_drain':
return { ...effect, params: { ...effect.params, ratio: effect.params.ratio * multiplier } };
case 'magic_shield':
return {
...effect,
params: {
...effect.params,
absorbRatio: effect.params.absorbRatio === undefined
? undefined
: effect.params.absorbRatio * multiplier,
},
};
case 'reflect':
return {
...effect,
params: {
...effect.params,
ratio: effect.params.ratio * multiplier,
ratioPerLayer: effect.params.ratioPerLayer === undefined
? undefined
: effect.params.ratioPerLayer * multiplier,
maxHpRatioPerAction: effect.params.maxHpRatioPerAction === undefined
? undefined
: effect.params.maxHpRatioPerAction * multiplier,
},
};
case 'tag_trigger':
return {
...effect,
params: {
...effect.params,
damageRatio: effect.params.damageRatio === undefined
? undefined
: effect.params.damageRatio * multiplier,
effects: effect.params.effects?.map((child) =>
scaleEffectNumericStrength(child, multiplier)),
},
};
case 'consume_status_trigger':
return {
...effect,
params: {
...effect.params,
effects: effect.params.effects.map((child) =>
scaleEffectNumericStrength(child, multiplier)),
},
};
case 'delayed_effect':
return {
...effect,
params: {
...effect.params,
effects: effect.params.effects.map((child) =>
scaleEffectNumericStrength(child, multiplier)),
record: effect.params.record
? {
...effect.params.record,
maxStoredValue: effect.params.record.maxStoredValue
? scaleValue(effect.params.record.maxStoredValue, multiplier)
: undefined,
}
: undefined,
},
};
case 'damage_memory':
return {
...effect,
params: {
...effect.params,
ratio: effect.params.ratio === undefined ? undefined : effect.params.ratio * multiplier,
maxStoredValue: effect.params.maxStoredValue
? scaleValue(effect.params.maxStoredValue, multiplier)
: undefined,
maxReleaseValue: effect.params.maxReleaseValue
? scaleValue(effect.params.maxReleaseValue, multiplier)
: undefined,
},
};
case 'buff_layer_modify':
case 'combat_resource_modify':
case 'runtime_counter_modify':
return {
...effect,
params: {
...effect.params,
effects: effect.params.effects?.map((child) =>
scaleEffectNumericStrength(child, multiplier)),
},
} as EffectConfig;
case 'ability_transform':
return {
...effect,
params: {
...effect.params,
bonusDamageMemory: effect.params.bonusDamageMemory
? {
...effect.params.bonusDamageMemory,
ratio: (effect.params.bonusDamageMemory.ratio ?? 1) * multiplier,
}
: undefined,
},
};
case 'hp_sacrifice_damage':
return {
...effect,
params: { ...effect.params, damagePerHp: effect.params.damagePerHp * multiplier },
};
case 'damage_defer':
return {
...effect,
params: {
...effect.params,
ratio: effect.params.ratio * multiplier,
memory: effect.params.memory
? {
...effect.params.memory,
maxStoredValue: effect.params.memory.maxStoredValue
? scaleValue(effect.params.memory.maxStoredValue, multiplier)
: undefined,
}
: undefined,
},
};
case 'dynamic_scalar':
return {
...effect,
params: {
...effect.params,
value: effect.params.value * multiplier,
cap: effect.params.cap === undefined ? undefined : effect.params.cap * multiplier,
},
};
case 'turn_state_counter':
case 'element_history':
return {
...effect,
params: {
...effect.params,
effects: effect.params.effects.map((child) =>
scaleEffectNumericStrength(child, multiplier)),
},
} as EffectConfig;
case 'effect_sequence':
return {
...effect,
params: {
...effect.params,
effects: effect.params.effects.map((child) =>
scaleEffectNumericStrength(child, multiplier)),
},
};
case 'status_transfer':
return {
...effect,
params: {
...effect.params,
effects: effect.params.effects?.map((child) =>
scaleEffectNumericStrength(child, multiplier)),
fallbackEffects: effect.params.fallbackEffects?.map((child) =>
scaleEffectNumericStrength(child, multiplier)),
},
};
case 'lifesteal':
return {
...effect,
params: {
...effect.params,
ratio: effect.params.ratio * multiplier,
maxHpRatioPerAction: effect.params.maxHpRatioPerAction * multiplier,
},
};
case 'damage_cap':
return {
...effect,
params: {
...effect.params,
maxHpRatio: multiplier > 0
? effect.params.maxHpRatio / multiplier
: effect.params.maxHpRatio,
},
};
case 'percent_damage_modifier':
return {
...effect,
params: {
...effect.params,
value: effect.params.value * multiplier,
cap: effect.params.cap === undefined ? undefined : effect.params.cap * multiplier,
},
};
case 'queue_action':
return {
...effect,
params: {
...effect.params,
effects: effect.params.effects.map((child) =>
scaleEffectNumericStrength(child, multiplier)),
cancelEffects: effect.params.cancelEffects?.map((child) =>
scaleEffectNumericStrength(child, multiplier)),
},
};
case 'resource_scaled_damage':
return {
...effect,
params: {
...effect.params,
baseCoefficient: effect.params.baseCoefficient * multiplier,
coefficientPerPoint: effect.params.coefficientPerPoint * multiplier,
bypassDefenseRatio: effect.params.bypassDefenseRatio === undefined
? undefined
: effect.params.bypassDefenseRatio * multiplier,
},
};
case 'dispel':
case 'cooldown_modify':
case 'buff_duration_modify':
case 'ability_lock':
case 'status_spread':
case 'buff_copy':
case 'next_hit_rule':
case 'ability_mode':
case 'death_prevent':
case 'buff_immunity':
case 'damage_immunity':
case 'skip_action':
return effect;
default:
return assertNever(effect);
}
}
export function scaleEffectListNumericStrength(
effects: EffectConfig[],
multiplier: number,
): EffectConfig[] {
return effects.map((effect) => scaleEffectNumericStrength(effect, multiplier));
}
@@ -69,10 +69,8 @@ export interface BattleRuntimeState {
export interface AbilityModeRuntime {
key: string;
mode: string;
phase: number;
remainingUses: number;
displayName: string;
firstAbilityId?: string;
cleanupBuffIds?: string[];
}
@@ -192,7 +190,6 @@ export function getActionStateViews(unit: Unit): ActionStateView[] {
type: 'ability_mode',
name: mode.displayName,
remainingActions: mode.remainingUses,
phase: mode.phase,
});
}
return views;
@@ -209,16 +206,13 @@ export function setAbilityMode(unit: Unit, mode: AbilityModeRuntime): void {
export function advanceAbilityMode(
unit: Unit,
key: string,
abilityId?: string,
): AbilityModeRuntime | undefined {
const state = getBattleRuntimeState(unit);
const current = state.abilityModes.get(key);
if (!current) return undefined;
const next = {
...current,
phase: current.phase + 1,
remainingUses: Math.max(0, current.remainingUses - 1),
firstAbilityId: current.firstAbilityId ?? abilityId,
};
if (next.remainingUses <= 0) {
state.abilityModes.delete(key);
@@ -312,16 +306,6 @@ export function clearMemory(unit: Unit, key: string): void {
getBattleRuntimeState(unit).memories.delete(key);
}
export function consumeMemoryRatio(unit: Unit, key: string, ratio: number): void {
const state = getBattleRuntimeState(unit);
const memory = state.memories.get(key);
if (!memory) return;
const consumed = Math.max(0, Math.min(1, ratio));
memory.amount = Math.max(0, memory.amount * (1 - consumed));
memory.count = memory.amount > 0 ? memory.count : 0;
if (memory.amount <= 0) state.memories.delete(key);
}
export function claimGlobalUniqueEffect(
unit: Unit,
key: string,
@@ -18,12 +18,11 @@ export class AbilityModeEffect extends GameplayEffect {
if (this.params.operation === 'clear') {
clearAbilityMode(context.caster, this.params.key);
} else if (this.params.operation === 'advance') {
advanceAbilityMode(context.caster, this.params.key, context.ability?.id);
advanceAbilityMode(context.caster, this.params.key);
} else if (this.params.mode) {
setAbilityMode(context.caster, {
key: this.params.key,
mode: this.params.mode,
phase: Math.max(1, Math.trunc(this.params.phase ?? 1)),
remainingUses: Math.max(1, Math.trunc(this.params.remainingUses ?? 1)),
displayName: this.params.displayName ?? this.params.mode,
cleanupBuffIds: this.params.cleanupBuffIds
@@ -1,24 +1,21 @@
import type { ConsumeStatusTriggerParams } from '../core/configs';
import { executeEffectConfigs } from '../core/effectExecutor';
import { scaleEffectNumericStrength } from '../core/effectStrengthScaler';
import { getDelayedBuffEffects } from '../core/runtimeState';
import { EffectRegistry } from '../factories/EffectRegistry';
import { EffectContext, GameplayEffect } from './Effect';
import { findMatchingBuffs, publishMechanicLog } from './advancedEffectUtils';
export class ConsumeStatusTriggerEffect extends GameplayEffect {
constructor(private params: ConsumeStatusTriggerParams) {
super();
if (params.scaleEffectsByLayer && params.scaleNumericEffectsByLayer) {
throw new Error('consume_status_trigger不能同时按层重复效果并缩放数值强度');
}
}
constructor(private params: ConsumeStatusTriggerParams) { super(); }
execute(context: EffectContext): void {
const unit = this.params.target === 'caster' ? context.caster : context.target;
const matched = findMatchingBuffs(unit, this.params.match);
const buff = matched[0];
if (!buff) return;
if (!buff) {
executeEffectConfigs(this.params.fallbackEffects ?? [], context);
return;
}
const consume = this.params.consume ?? 'one';
const beforeLayer = buff.getLayer();
@@ -53,13 +50,9 @@ export class ConsumeStatusTriggerEffect extends GameplayEffect {
const configuredEffects = this.params.effects.length > 0
? this.params.effects
: delayedEffects ?? [];
const effects = this.params.scaleNumericEffectsByLayer
? configuredEffects.map((effect) =>
scaleEffectNumericStrength(effect, consumedLayers))
: configuredEffects;
const repeats = this.params.scaleEffectsByLayer ? consumedLayers : 1;
for (let index = 0; index < repeats; index += 1) {
executeEffectConfigs(effects, context);
executeEffectConfigs(configuredEffects, context);
}
}
}
@@ -1,46 +0,0 @@
import { GameplayTags } from '@shared/engine/shared/tag-domain';
import type { DamageCapParams } from '../core/configs';
import type { DamageEvent } from '../core/events';
import { nextRuntimeSequence } from '../core/runtimeState';
import { AttributeType, DamageSource, DamageType } from '../core/types';
import { EffectRegistry } from '../factories/EffectRegistry';
import { DelayedRuntimeBuff } from './DelayedEffect';
import type { EffectContext } from './Effect';
import { GameplayEffect } from './Effect';
export class DamageCapEffect extends GameplayEffect {
constructor(private readonly params: DamageCapParams) {
super();
}
execute(context: EffectContext): void {
if (context.triggerEvent?.type !== 'DamageEvent') return;
const event = context.triggerEvent as DamageEvent;
const cap = Math.round(event.target.getMaxHp() * this.params.maxHpRatio);
const overflow = Math.max(0, event.finalDamage - cap);
if (overflow <= 0) return;
event.finalDamage = cap;
const turns = Math.max(0, Math.trunc(this.params.deferOverflowTurns ?? 0));
if (turns <= 0) return;
event.target.buffs.addBuff(
new DelayedRuntimeBuff({
id: `damage_cap_${nextRuntimeSequence(event.target, 'damage_cap')}`,
name: '彼岸余波',
description: `${turns}回合后结算被延后的伤害。`,
delayTurns: turns,
effects: [{
type: 'damage',
params: {
value: { base: overflow, attribute: AttributeType.ATK, coefficient: 0 },
damageType: event.damageType ?? DamageType.TRUE,
damageSource: DamageSource.DELAYED,
},
}],
tags: [GameplayTags.BUFF.TYPE.DEBUFF],
}),
event.caster,
);
}
}
EffectRegistry.getInstance().register('damage_cap', (params) => new DamageCapEffect(params));
@@ -3,14 +3,12 @@ import { EventBus } from '../core/EventBus';
import {
DamageRequestEvent,
DamageTakenEvent,
AbilityCostPaidEvent,
HealEvent,
ShieldBreakEvent,
ShieldEvent,
} from '../core/events';
import {
clearMemory,
consumeMemoryRatio,
readMemory,
rememberAmount,
} from '../core/runtimeState';
@@ -58,21 +56,7 @@ export class DamageMemoryEffect extends GameplayEffect {
const memory = readMemory(owner, this.params.key);
const storedAmount =
memory.amount > 0 ? memory.amount : this.getRecordAmount(context);
const consumeRatio = this.params.consume === false
? 1
: Math.max(0, Math.min(1, this.params.consumeRatio ?? 1));
const sourceAmount = storedAmount * consumeRatio;
const releaseCap = this.params.maxReleaseValue
? ValueCalculator.calculate(
this.params.maxReleaseValue,
context.caster,
context.target,
)
: Number.POSITIVE_INFINITY;
const amount = Math.min(
releaseCap,
Math.round(sourceAmount * (this.params.ratio ?? 1)),
);
const amount = Math.round(storedAmount * (this.params.ratio ?? 1));
if (amount <= 0) return;
switch (this.params.releaseAs ?? 'damage') {
@@ -145,11 +129,7 @@ export class DamageMemoryEffect extends GameplayEffect {
});
if (this.params.consume !== false) {
if (this.params.consumeRatio !== undefined) {
consumeMemoryRatio(owner, this.params.key, this.params.consumeRatio);
} else {
clearMemory(owner, this.params.key);
}
clearMemory(owner, this.params.key);
}
}
@@ -193,12 +173,6 @@ export class DamageMemoryEffect extends GameplayEffect {
private getRecordAmount(context: EffectContext): number {
const event = context.triggerEvent;
if (!event) return 0;
if (
this.params.event === 'ability_cost_paid' &&
event.type === 'AbilityCostPaidEvent'
) {
return (event as AbilityCostPaidEvent).hpPaid;
}
if (this.params.event === 'heal' && event.type === 'HealEvent') {
return (event as HealEvent).healAmount;
}
@@ -1,6 +1,8 @@
import { DispelParams } from '../core/configs';
import { EventBus } from '../core/EventBus';
import { DispelEvent } from '../core/events';
import { BuffType } from '../core/types';
import { executeEffectConfigs } from '../core/effectExecutor';
import { EffectRegistry } from '../factories/EffectRegistry';
import { EffectContext, GameplayEffect } from './Effect';
@@ -14,16 +16,25 @@ export class DispelEffect extends GameplayEffect {
}
execute(context: EffectContext): void {
const { target, caster, ability } = context;
const { caster, ability } = context;
const target = this.params.recipient === 'caster' ? caster : context.target;
const buffs = target.buffs.getAllBuffs();
// 过滤匹配标签的 Buff
const matchBuffs = this.params.targetTag
? buffs.filter((b) => b.tags.hasTag(this.params.targetTag!))
: buffs;
const removableBuffs = matchBuffs.filter((buff) => buff.dispelPolicy === 'normal');
const statusBuffs = this.params.status
? matchBuffs.filter((buff) => this.params.status === 'positive'
? buff.type === BuffType.BUFF
: buff.type === BuffType.DEBUFF || buff.type === BuffType.CONTROL)
: matchBuffs;
const removableBuffs = statusBuffs.filter((buff) => buff.dispelPolicy === 'normal');
if (removableBuffs.length === 0) return;
if (removableBuffs.length === 0) {
executeEffectConfigs(this.params.fallbackEffects ?? [], context);
return;
}
// 确定移除数量
const countToRemove = Math.min(
@@ -39,7 +50,10 @@ export class DispelEffect extends GameplayEffect {
}
}
if (removedBuffNames.length === 0) return;
if (removedBuffNames.length === 0) {
executeEffectConfigs(this.params.fallbackEffects ?? [], context);
return;
}
// 发布驱散事件
EventBus.instance.publish<DispelEvent>({
@@ -50,6 +64,7 @@ export class DispelEffect extends GameplayEffect {
ability,
removedBuffNames,
});
executeEffectConfigs(this.params.effects ?? [], context);
}
}
@@ -16,11 +16,6 @@ export class LifestealEffect extends GameplayEffect {
if (context.triggerEvent?.type !== 'DamageTakenEvent') return;
const event = context.triggerEvent as DamageTakenEvent;
if (event.caster !== context.caster || event.damageSource !== DamageSource.DIRECT) return;
if (
this.params.variantIds?.length &&
(!event.ability?.runtimeVariantId ||
!this.params.variantIds.includes(event.ability.runtimeVariantId))
) return;
const requested = Math.round(event.damageTaken * this.params.ratio);
const amount = claimActionAmount(
context.caster,
@@ -18,7 +18,7 @@ export class ShieldEffect extends GameplayEffect {
const target = this.params.target === 'caster' ? caster : context.target;
// 使用统一计算器计算护盾值
const shieldAmount = ValueCalculator.calculate(this.params.value, caster);
const shieldAmount = ValueCalculator.calculate(this.params.value, caster, target);
if (shieldAmount <= 0) return;
@@ -1,48 +0,0 @@
import type { StatusTransferParams } from '../core/configs';
import { BuffType } from '../core/types';
import { EffectRegistry } from '../factories/EffectRegistry';
import type { Unit } from '../units/Unit';
import type { EffectContext } from './Effect';
import { GameplayEffect } from './Effect';
import { executeEffectConfigs } from '../core/effectExecutor';
export class StatusTransferEffect extends GameplayEffect {
constructor(private readonly params: StatusTransferParams) {
super();
}
execute(context: EffectContext): void {
const from = this.unit(context, this.params.from);
const to = this.params.to ? this.unit(context, this.params.to) : undefined;
const matches = from.buffs.getAllBuffs().filter((buff) =>
buff.dispelPolicy === 'normal' &&
(this.params.status === 'positive'
? buff.type === BuffType.BUFF
: buff.type === BuffType.DEBUFF || buff.type === BuffType.CONTROL),
);
const max = Math.max(1, Math.trunc(this.params.maxCount ?? 1));
const selected = matches.slice(0, max);
if (selected.length === 0) {
executeEffectConfigs(this.params.fallbackEffects ?? [], context);
return;
}
for (const buff of selected) {
const moved = buff.clone();
const source = buff.getSource();
if (!from.buffs.removeBuffDispel(buff.id)) continue;
if (this.params.operation === 'move' && to) {
to.buffs.addBuff(moved, source ?? undefined, { ability: context.ability });
}
executeEffectConfigs(this.params.effects ?? [], context);
}
}
private unit(context: EffectContext, side: 'caster' | 'target'): Unit {
return side === 'caster' ? context.caster : context.target;
}
}
EffectRegistry.getInstance().register(
'status_transfer',
(params) => new StatusTransferEffect(params),
);
@@ -199,15 +199,9 @@ export function describeEffectCore(
? '推进战斗形态'
: '结束战斗形态';
case 'status_transfer':
return `${effect.params.operation === 'move' ? '转移' : '移除'}${effect.params.status === 'positive' ? '增益' : '减益'}状态`;
case 'lifesteal':
return `直接伤害吸血 ${formatAffixPercent(effect.params.ratio)}`;
case 'damage_cap':
return `单次伤害不超过最大气血 ${formatAffixPercent(effect.params.maxHpRatio)}`;
default: {
const exhaustive: never = effect;
return (exhaustive as EffectConfig).type;
@@ -234,8 +228,6 @@ function describeMemoryEvent(event?: string): string {
return '护盾承伤量';
case 'critical_taken':
return '受到暴击伤害';
case 'ability_cost_paid':
return '支付气血';
case 'damage_taken':
default:
return '受到伤害';
@@ -22,6 +22,4 @@ export * from './SkipActionEffect';
export * from './QueueActionEffect';
export * from './ResourceScaledDamageEffect';
export * from './AbilityModeEffect';
export * from './StatusTransferEffect';
export * from './LifestealEffect';
export * from './DamageCapEffect';
@@ -35,10 +35,12 @@ function channelForDamage(effect: Extract<EffectConfig, { type: 'damage' }>) {
function nestedEffects(effect: EffectConfig): EffectConfig[] {
const params = effect.params as {
effects?: EffectConfig[];
fallbackEffects?: EffectConfig[];
cancelEffects?: EffectConfig[];
};
return [
...(params.effects ?? []),
...(params.fallbackEffects ?? []),
...(params.cancelEffects ?? []),
];
}
@@ -46,17 +48,18 @@ function nestedEffects(effect: EffectConfig): EffectConfig[] {
export function analyzeAbilityCapabilities(
config: Pick<
AbilityConfig,
'effects' | 'castEffects' | 'listeners' | 'tags' | 'slug'
> & Pick<AbilityConfig, 'variants'>,
'effects' | 'completionEffects' | 'effectLayers' | 'castEffects' | 'listeners' | 'tags' | 'slug'
>,
): AbilityCapabilitySummary {
const queue: EffectConfig[] = [
...(config.effects ?? []),
...(config.completionEffects ?? []),
...(config.effectLayers?.flatMap((layer) => [
...(layer.effects ?? []),
...(layer.completionEffects ?? []),
]) ?? []),
...(config.castEffects ?? []),
...(config.listeners?.flatMap((listener) => listener.effects) ?? []),
...(config.variants?.flatMap((variant) => [
...(variant.effects ?? []),
...(variant.castEffects ?? []),
]) ?? []),
];
const damageChannels = new Set<AbilityDamageChannel>();
const intents = new Set<
@@ -1,7 +1,7 @@
import { Ability } from '../abilities/Ability';
import { DataDrivenActiveSkill } from '../abilities/DataDrivenActiveSkill';
import { DataDrivenPassiveAbility } from '../abilities/DataDrivenPassiveAbility';
import { DynamicDataDrivenActiveSkill } from '../abilities/DynamicDataDrivenActiveSkill';
import { LayeredDataDrivenActiveSkill } from '../abilities/LayeredDataDrivenActiveSkill';
import { TargetPolicy } from '../abilities/TargetPolicy';
import { GameplayTags } from '@shared/engine/shared/tag-domain';
import { AbilityConfig, EffectConfig, ListenerConfig } from '../core/configs';
@@ -10,6 +10,7 @@ import { AbilityId, AbilityType } from '../core/types';
import { GameplayEffect } from '../effects/Effect';
import { EffectRegistry } from './EffectRegistry';
import { analyzeAbilityCapabilities } from './AbilityCapabilityAnalyzer';
import { validateAbilityEffectPlans } from '../core/abilityEffectPlan';
// 统一加载所有效果以触发它们的自我注册 (Side-effects loading)
import '../effects';
@@ -35,14 +36,19 @@ export class AbilityFactory {
* 根据配置创建技能实例
*/
static create(config: AbilityConfig): Ability {
validateAbilityEffectPlans(config);
const id = config.slug as AbilityId;
const name = config.name;
const abilityTags = this.validateAbilityTags(config);
// 1. 处理主动技能
if (config.type === AbilityType.ACTIVE_SKILL) {
if (config.variants?.length) {
const skill = new DynamicDataDrivenActiveSkill(id, name, {
if (
config.completionEffects?.length ||
config.effectLayers?.length ||
config.effectPlans?.length
) {
const skill = new LayeredDataDrivenActiveSkill(id, name, {
description: config.description,
mpCost: config.mpCost ?? 0,
hpCost: config.hpCost ?? 0,
@@ -56,8 +62,10 @@ export class AbilityFactory {
config.selectionProfile ?? analyzeAbilityCapabilities(config).selectionProfile,
castConditions: config.castConditions,
effects: config.effects,
completionEffects: config.completionEffects,
castEffects: config.castEffects,
variants: config.variants,
effectLayers: config.effectLayers,
effectPlans: config.effectPlans,
});
skill.tags.addTags(abilityTags);
return skill;
+1 -1
View File
@@ -34,7 +34,7 @@ export {
// Data-Driven System
export { DataDrivenActiveSkill } from './abilities/DataDrivenActiveSkill';
export { DynamicDataDrivenActiveSkill } from './abilities/DynamicDataDrivenActiveSkill';
export { LayeredDataDrivenActiveSkill } from './abilities/LayeredDataDrivenActiveSkill';
export { DataDrivenBuff } from './buffs/DataDrivenBuff';
export { AbilityFactory } from './factories/AbilityFactory';
export { BuffFactory } from './factories/BuffFactory';
@@ -211,6 +211,7 @@ export class BattleStateRecorder {
.map((skill) => ({
skillId: skill.id,
skillName: skill.name,
runtimePlanId: skill.runtimePlanId,
description: skill.description,
current: skill.currentCooldown,
max: skill.maxCooldown,
@@ -21,6 +21,8 @@ export interface BuffStateView {
export interface CooldownStateView {
skillId: string;
skillName: string;
/** 当前效果计划;基础计划不存在时省略。 */
runtimePlanId?: string;
description?: string;
/** 当前剩余冷却回合;0 = 可用 */
current: number;
@@ -0,0 +1,122 @@
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import type { ActiveSkill } from '../../abilities/ActiveSkill';
import { EventBus } from '../../core/EventBus';
import type { AbilityCostPaidEvent, DamageRequestEvent, DamageTakenEvent } from '../../core/events';
import { AbilityType, AttributeType, DamageSource, DamageType } from '../../core/types';
import { AbilityFactory } from '../../factories/AbilityFactory';
import { GameplayTags } from '../../../shared/tag-domain';
import { Unit } from '../../units/Unit';
function unit(id: string): Unit {
return new Unit(id, id, {
[AttributeType.VITALITY]: 100,
[AttributeType.SPIRIT]: 10,
[AttributeType.WISDOM]: 10,
[AttributeType.SPEED]: 10,
[AttributeType.WILLPOWER]: 10,
});
}
describe('主动技能气血成本', () => {
beforeEach(() => EventBus.instance.reset());
afterEach(() => EventBus.instance.reset());
it('按施法前当前气血向上取整并至少保留1点', () => {
const caster = unit('caster');
const target = unit('target');
caster.setHp(101);
const skill = AbilityFactory.create({
slug: 'ratio-cost', name: '比例成本', type: AbilityType.ACTIVE_SKILL,
tags: [GameplayTags.ABILITY.KIND.SKILL, GameplayTags.ABILITY.FUNCTION.BUFF],
costs: [{ resource: 'hp', mode: 'current_hp_ratio', ratio: 0.08, minimum: 1, retain: 1 }],
effects: [],
});
skill.setOwner(caster);
const events: AbilityCostPaidEvent[] = [];
EventBus.instance.subscribe<AbilityCostPaidEvent>('AbilityCostPaidEvent', (event) => events.push(event));
skill.execute({ caster, target });
expect(caster.getCurrentHp()).toBe(92);
expect(events[0]).toMatchObject({ beforeHp: 101, afterHp: 92, hpPaid: 9 });
caster.setHp(1);
expect(skill.canTrigger({ caster, target })).toBe(false);
});
it('条件成本在施法准备时按战斗资源选择并冻结', () => {
const caster = unit('caster');
const target = unit('target');
caster.combatResources.define({ id: 'intent', name: '战意', initial: 6, max: 6 });
const skill = AbilityFactory.create({
slug: 'conditional-cost', name: '转相', type: AbilityType.ACTIVE_SKILL,
tags: [GameplayTags.ABILITY.KIND.SKILL, GameplayTags.ABILITY.FUNCTION.BUFF],
costs: [
{
resource: 'hp', mode: 'current_hp_ratio', ratio: 0.04,
conditions: [{ type: 'combat_resource_below', params: { scope: 'caster', resourceId: 'intent', value: 6 } }],
},
{
resource: 'hp', mode: 'current_hp_ratio', ratio: 0.08,
conditions: [{ type: 'combat_resource_at_least', params: { scope: 'caster', resourceId: 'intent', value: 6 } }],
},
],
effects: [],
}) as ActiveSkill;
skill.setOwner(caster);
const before = caster.getCurrentHp();
skill.prepareCast({ caster, target });
caster.combatResources.modify('intent', -6);
skill.execute({ caster, target });
expect(before - caster.getCurrentHp()).toBe(Math.ceil(before * 0.08));
});
it('气血成本原子支付,不发布受击事件', () => {
const caster = unit('caster');
const skill = AbilityFactory.create({
slug: 'atomic-cost', name: '原子成本', type: AbilityType.ACTIVE_SKILL,
tags: [GameplayTags.ABILITY.KIND.SKILL, GameplayTags.ABILITY.FUNCTION.BUFF],
costs: [{ resource: 'hp', mode: 'current_hp_ratio', ratio: 0.1, retain: 1 }],
effects: [],
});
skill.setOwner(caster);
let damageEvents = 0;
EventBus.instance.subscribe<DamageTakenEvent>('DamageTakenEvent', () => { damageEvents += 1; });
skill.execute({ caster, target: caster });
expect(damageEvents).toBe(0);
expect(caster.isAlive()).toBe(true);
});
it('伤害动态标量读取施法开始时目标已损气血', () => {
const caster = unit('caster');
const target = unit('target');
target.setHp(Math.round(target.getMaxHp() * 0.5));
const attack = caster.attributes.getValue(AttributeType.ATK);
const skill = AbilityFactory.create({
slug: 'missing-hp-snapshot', name: '摘心', type: AbilityType.ACTIVE_SKILL,
tags: [
GameplayTags.ABILITY.KIND.SKILL,
GameplayTags.ABILITY.FUNCTION.DAMAGE,
GameplayTags.ABILITY.CHANNEL.PHYSICAL,
],
effects: [{
type: 'damage',
params: {
value: { attribute: AttributeType.ATK, coefficient: 0.6 },
damageType: DamageType.PHYSICAL,
damageSource: DamageSource.DIRECT,
dynamicScalars: [{
source: 'target_missing_hp_ratio', attribute: AttributeType.ATK,
coefficientCap: 0.4, timing: 'cast',
}],
},
}],
}) as ActiveSkill;
skill.setOwner(caster);
const requests: DamageRequestEvent[] = [];
EventBus.instance.subscribe<DamageRequestEvent>('DamageRequestEvent', (event) => requests.push(event));
skill.prepareCast({ caster, target });
target.heal(target.getMaxHp());
skill.execute({ caster, target });
expect(requests[0].baseDamage).toBeCloseTo(Math.round(attack * 0.6) + attack * 0.2, 5);
});
});
@@ -1,505 +0,0 @@
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import { DynamicDataDrivenActiveSkill } from '../../abilities/DynamicDataDrivenActiveSkill';
import type { ActiveSkill } from '../../abilities/ActiveSkill';
import { StackRule } from '../../buffs/Buff';
import { TargetPolicy } from '../../abilities/TargetPolicy';
import { EventBus } from '../../core/EventBus';
import { checkConditions } from '../../core/conditionEvaluator';
import { scaleEffectNumericStrength } from '../../core/effectStrengthScaler';
import type {
AbilityCostPaidEvent,
DamageRequestEvent,
DamageTakenEvent,
HpChangedEvent,
SkillCastEvent,
SkillPreCastEvent,
} from '../../core/events';
import {
beginRuntimeAction,
readAbilityMode,
readMemory,
setAbilityMode,
} from '../../core/runtimeState';
import { AbilityType, AttributeType, BuffType, DamageSource, DamageType } from '../../core/types';
import { AbilityFactory } from '../../factories/AbilityFactory';
import { BuffFactory } from '../../factories/BuffFactory';
import { ActionExecutionSystem } from '../../systems/ActionExecutionSystem';
import { DamageSystem } from '../../systems/DamageSystem';
import { Unit } from '../../units/Unit';
import { GameplayTags } from '@shared/engine/shared/tag-domain';
function unit(id: string): Unit {
return new Unit(id, id, {
[AttributeType.VITALITY]: 100,
[AttributeType.SPIRIT]: 10,
[AttributeType.WISDOM]: 10,
[AttributeType.SPEED]: 10,
[AttributeType.WILLPOWER]: 10,
});
}
describe('气血成本与动态技能变体', () => {
beforeEach(() => EventBus.instance.reset());
afterEach(() => EventBus.instance.reset());
it('按施法前当前气血向上取整、最低1点并保留1点气血', () => {
const caster = unit('caster');
const target = unit('target');
caster.setHp(101);
const skill = AbilityFactory.create({
slug: 'ratio-cost', name: '比例成本', type: AbilityType.ACTIVE_SKILL,
tags: [GameplayTags.ABILITY.KIND.SKILL, GameplayTags.ABILITY.FUNCTION.BUFF],
costs: [{ resource: 'hp', mode: 'current_hp_ratio', ratio: 0.08, minimum: 1, retain: 1 }],
effects: [],
});
skill.setOwner(caster);
const events: AbilityCostPaidEvent[] = [];
EventBus.instance.subscribe<AbilityCostPaidEvent>('AbilityCostPaidEvent', (event) => events.push(event));
expect(skill.canTrigger({ caster, target })).toBe(true);
skill.execute({ caster, target });
expect(caster.getCurrentHp()).toBe(92);
expect(events[0]).toMatchObject({ beforeHp: 101, afterHp: 92, hpPaid: 9 });
caster.setHp(1);
expect(skill.canTrigger({ caster, target })).toBe(false);
});
it('任意配置气血线直接读取成本事件前后比例', () => {
const caster = unit('caster');
const target = unit('target');
const event = {
type: 'AbilityCostPaidEvent', timestamp: Date.now(), caster,
ability: AbilityFactory.create({
slug: 'threshold-source', name: '阈值来源', type: AbilityType.ACTIVE_SKILL,
tags: [GameplayTags.ABILITY.KIND.SKILL, GameplayTags.ABILITY.FUNCTION.BUFF],
}),
beforeHp: 650, afterHp: 620, beforeMp: 0, afterMp: 0,
hpPaid: 30, mpPaid: 0, beforeHpRatio: 0.65, afterHpRatio: 0.62,
} satisfies AbilityCostPaidEvent;
expect(checkConditions({ caster, target, triggerEvent: event }, [{
type: 'ability_cost_crossed', params: { value: 0.63 },
}])).toBe(true);
expect(checkConditions({ caster, target, triggerEvent: event }, [{
type: 'ability_cost_crossed', params: { value: 0.7 },
}])).toBe(false);
});
it('所有气血写入路径发布统一变化事件并保留原因', () => {
const caster = unit('caster');
const events: HpChangedEvent[] = [];
EventBus.instance.subscribe<HpChangedEvent>('HpChangedEvent', (event) => events.push(event));
caster.takeDamage(10);
caster.heal(4);
caster.setHp(caster.getCurrentHp() - 3, 'ability_cost');
expect(events.map((event) => event.reason)).toEqual(['damage', 'heal', 'ability_cost']);
expect(events.map((event) => event.delta)).toEqual([-10, 4, -3]);
});
it('气血成本是原子支付,不发布受击事件也不进入伤害管道', () => {
const caster = unit('caster');
const skill = AbilityFactory.create({
slug: 'atomic-cost', name: '原子成本', type: AbilityType.ACTIVE_SKILL,
tags: [GameplayTags.ABILITY.KIND.SKILL, GameplayTags.ABILITY.FUNCTION.BUFF],
costs: [{ resource: 'hp', mode: 'current_hp_ratio', ratio: 0.1, retain: 1 }],
effects: [],
});
skill.setOwner(caster);
let damageEvents = 0;
let costEvents = 0;
EventBus.instance.subscribe<DamageTakenEvent>('DamageTakenEvent', () => { damageEvents += 1; });
EventBus.instance.subscribe<AbilityCostPaidEvent>('AbilityCostPaidEvent', () => { costEvents += 1; });
skill.execute({ caster, target: caster });
expect(costEvents).toBe(1);
expect(damageEvents).toBe(0);
expect(caster.isAlive()).toBe(true);
});
it('按首段效果前的目标已损气血线性追加系数', () => {
const caster = unit('caster');
const target = unit('target');
target.setHp(Math.round(target.getMaxHp() * 0.5));
const attack = caster.attributes.getValue(AttributeType.ATK);
const skill = AbilityFactory.create({
slug: 'missing-hp-snapshot', name: '摘心', type: AbilityType.ACTIVE_SKILL,
tags: [GameplayTags.ABILITY.KIND.SKILL, GameplayTags.ABILITY.FUNCTION.DAMAGE, GameplayTags.ABILITY.CHANNEL.PHYSICAL],
effects: [{
type: 'damage',
params: {
value: { attribute: AttributeType.ATK, coefficient: 0.6 },
damageType: DamageType.PHYSICAL,
damageSource: DamageSource.DIRECT,
dynamicScalars: [{
source: 'target_missing_hp_ratio',
attribute: AttributeType.ATK,
coefficientCap: 0.4,
timing: 'cast',
}],
},
}],
}) as ActiveSkill;
skill.setOwner(caster);
const requests: DamageRequestEvent[] = [];
EventBus.instance.subscribe<DamageRequestEvent>('DamageRequestEvent', (event) => requests.push(event));
skill.prepareCast({ caster, target });
skill.execute({ caster, target });
expect(requests).toHaveLength(1);
expect(requests[0].baseDamage).toBeCloseTo(Math.round(attack * 0.6) + attack * 0.2, 5);
expect(requests[0].damageComponents).toContainEqual(expect.objectContaining({
kind: `target_missing_hp:${AttributeType.ATK}`,
segmentMultiplier: 0.2,
}));
});
it('条件暴击读取支付后快照,不受前置治疗改变', () => {
const caster = unit('caster');
const target = unit('target');
caster.setHp(Math.floor(caster.getMaxHp() * 0.38));
const skill = AbilityFactory.create({
slug: 'cast-critical-snapshot', name: '三叩', type: AbilityType.ACTIVE_SKILL,
tags: [
GameplayTags.ABILITY.KIND.SKILL,
GameplayTags.ABILITY.FUNCTION.DAMAGE,
GameplayTags.ABILITY.FUNCTION.HEAL,
GameplayTags.ABILITY.CHANNEL.PHYSICAL,
],
costs: [{ resource: 'hp', mode: 'current_hp_ratio', ratio: 0.1, retain: 1 }],
effects: [
{ type: 'heal', params: { value: { targetMaxHpRatio: 0.1 }, recipient: 'caster' } },
{
type: 'damage',
params: {
value: { attribute: AttributeType.ATK, coefficient: 0.8 },
damageType: DamageType.PHYSICAL,
forceCriticalConditions: [{
type: 'hp_below',
params: { scope: 'caster', value: 0.35, timing: 'cast' },
}],
},
},
],
}) as ActiveSkill;
skill.setOwner(caster);
let request: DamageRequestEvent | undefined;
EventBus.instance.subscribe<DamageRequestEvent>('DamageRequestEvent', (event) => { request = event; });
EventBus.instance.subscribe<AbilityCostPaidEvent>('AbilityCostPaidEvent', () => {
caster.heal(Math.round(caster.getMaxHp() * 0.2));
});
skill.prepareCast({ caster, target });
skill.execute({ caster, target });
expect(caster.getHpPercent()).toBeGreaterThan(0.35);
expect(request).toMatchObject({ forceCritical: true, isCritical: true });
});
it('记忆部分释放只使用实际消费量,并遵守释放上限', () => {
const caster = unit('caster');
const target = unit('target');
const record = AbilityFactory.createEffect({
type: 'damage_memory',
params: { key: 'tide', mode: 'record', event: 'ability_cost_paid', target: 'caster' },
})!;
const release = AbilityFactory.createEffect({
type: 'damage_memory',
params: {
key: 'tide', mode: 'release', ratio: 2, releaseAs: 'follow_up',
target: 'caster', consume: true, consumeRatio: 0.5,
maxReleaseValue: { base: 60 },
},
})!;
record.execute({
caster,
target,
triggerEvent: {
type: 'AbilityCostPaidEvent', timestamp: Date.now(), caster,
ability: AbilityFactory.create({
slug: 'record-source', name: '记录', type: AbilityType.ACTIVE_SKILL,
tags: [GameplayTags.ABILITY.KIND.SKILL, GameplayTags.ABILITY.FUNCTION.BUFF],
}),
beforeHp: 200, afterHp: 100, beforeMp: 0, afterMp: 0,
hpPaid: 100, mpPaid: 0, beforeHpRatio: 1, afterHpRatio: 0.5,
},
});
const requests: DamageRequestEvent[] = [];
EventBus.instance.subscribe<DamageRequestEvent>('DamageRequestEvent', (event) => requests.push(event));
release.execute({ caster, target });
expect(requests[0].baseDamage).toBe(60);
expect(readMemory(caster, 'tide').amount).toBe(50);
});
it('按消费层数只缩放数值强度,状态转移仍只执行一次并保留原来源', () => {
const caster = unit('caster');
const target = unit('target');
const originalSource = unit('original-source');
const marker = BuffFactory.create({
id: 'two-layer-marker', name: '两层标记', type: BuffType.BUFF,
duration: -1, stackRule: StackRule.STACK_LAYER, maxLayers: 2,
});
caster.buffs.addBuff(marker, caster);
caster.buffs.addBuff(marker.clone(), caster);
for (const id of ['negative-a', 'negative-b']) {
caster.buffs.addBuff(BuffFactory.create({
id, name: id, type: BuffType.DEBUFF, duration: 3,
stackRule: StackRule.OVERRIDE,
}), originalSource);
}
const consume = AbilityFactory.createEffect({
type: 'consume_status_trigger',
params: {
match: { id: 'two-layer-marker' }, consume: 2, target: 'caster',
scaleNumericEffectsByLayer: true,
effects: [
{ type: 'damage', params: { value: { attribute: AttributeType.ATK, coefficient: 0.2 } } },
{ type: 'status_transfer', params: { operation: 'move', from: 'caster', to: 'target', status: 'negative', maxCount: 1 } },
],
},
})!;
const requests: DamageRequestEvent[] = [];
EventBus.instance.subscribe<DamageRequestEvent>('DamageRequestEvent', (event) => requests.push(event));
consume.execute({ caster, target });
expect(requests).toHaveLength(1);
expect(requests[0].damageComponents).toContainEqual(expect.objectContaining({ segmentMultiplier: 0.4 }));
expect(caster.buffs.getAllBuffs().filter((buff) => buff.type === BuffType.DEBUFF)).toHaveLength(1);
const moved = target.buffs.getAllBuffs().find((buff) => buff.type === BuffType.DEBUFF);
expect(moved?.getSource()).toBe(originalSource);
expect(moved?.getDuration()).toBe(3);
});
it('数值缩放递归进入嵌套消费效果,离散消费次数保持一次', () => {
const caster = unit('caster');
const target = unit('target');
const addLayered = (unit: Unit, id: string, layers: number) => {
const marker = BuffFactory.create({
id, name: id, type: BuffType.BUFF, duration: -1,
stackRule: StackRule.STACK_LAYER, maxLayers: layers,
});
for (let index = 0; index < layers; index += 1) unit.buffs.addBuff(marker.clone(), unit);
};
addLayered(caster, 'outer', 2);
addLayered(target, 'inner', 3);
const effect = AbilityFactory.createEffect({
type: 'consume_status_trigger',
params: {
match: { id: 'outer' }, consume: 2, target: 'caster',
scaleNumericEffectsByLayer: true,
effects: [{
type: 'consume_status_trigger',
params: {
match: { id: 'inner' }, consume: 'all', target: 'target',
scaleEffectsByLayer: true,
effects: [{ type: 'damage', params: { value: { attribute: AttributeType.ATK, coefficient: 0.2 } } }],
},
}],
},
})!;
const requests: DamageRequestEvent[] = [];
EventBus.instance.subscribe<DamageRequestEvent>('DamageRequestEvent', (event) => requests.push(event));
effect.execute({ caster, target });
expect(requests).toHaveLength(3);
expect(requests.every((event) =>
event.damageComponents?.some((component) => component.segmentMultiplier === 0.4),
)).toBe(true);
expect(target.buffs.getAllBuffIds()).not.toContain('inner');
});
it('同一消费效果禁止同时重复次数与缩放数值', () => {
expect(() => AbilityFactory.createEffect({
type: 'consume_status_trigger',
params: {
match: { id: 'marker' }, consume: 'all', effects: [],
scaleEffectsByLayer: true, scaleNumericEffectsByLayer: true,
},
})).toThrow('不能同时');
});
it('数值越低越强的伤害上限按反方向缩放', () => {
const scaled = scaleEffectNumericStrength({
type: 'damage_cap', params: { maxHpRatio: 0.3, deferOverflowTurns: 1 },
}, 1.2);
expect(scaled.type).toBe('damage_cap');
if (scaled.type === 'damage_cap') expect(scaled.params.maxHpRatio).toBeCloseTo(0.25);
});
it('数值缩放强化吸血与共享上限,但不缩放冷却回合等离散语义', () => {
const lifesteal = scaleEffectNumericStrength({
type: 'lifesteal', params: { ratio: 0.25, maxHpRatioPerAction: 0.08 },
}, 1.2);
expect(lifesteal).toMatchObject({
type: 'lifesteal', params: { ratio: 0.3, maxHpRatioPerAction: 0.096 },
});
const cooldown = scaleEffectNumericStrength({
type: 'cooldown_modify', params: { cdModifyValue: -2 },
}, 1.2);
expect(cooldown).toEqual({ type: 'cooldown_modify', params: { cdModifyValue: -2 } });
});
it('protected状态拒绝普通驱散但仍可由机制主动清理', () => {
const caster = unit('caster');
const target = unit('target');
target.buffs.addBuff(BuffFactory.create({
id: 'normal', name: '普通', type: BuffType.BUFF, duration: 2,
stackRule: StackRule.OVERRIDE,
}), caster);
target.buffs.addBuff(BuffFactory.create({
id: 'protected', name: '保护', type: BuffType.BUFF, duration: -1,
stackRule: StackRule.OVERRIDE, dispelPolicy: 'protected',
}), caster);
const dispel = AbilityFactory.createEffect({
type: 'dispel', params: { maxCount: 2 },
})!;
dispel.execute({ caster, target });
expect(target.buffs.getAllBuffIds()).toEqual(['protected']);
expect(target.buffs.removeBuffDispel('protected')).toBe(false);
target.buffs.removeBuff('protected');
expect(target.buffs.getAllBuffIds()).toHaveLength(0);
});
it('正式执行前资源变化会取消原承诺并改用无标签徒手攻击', () => {
const caster = unit('caster');
const target = unit('target');
const skill = AbilityFactory.create({
slug: 'expensive-sect-technique', name: '宗门神通', type: AbilityType.ACTIVE_SKILL,
tags: [GameplayTags.ABILITY.KIND.SKILL, GameplayTags.ABILITY.FUNCTION.BUFF, 'Ability.Sect.Test.Technique'],
costs: [{ resource: 'hp', mode: 'current_hp_ratio', ratio: 0.5, retain: 1 }],
cooldown: 3,
effects: [],
}) as ActiveSkill;
skill.setOwner(caster);
skill.setActive(true);
const system = new ActionExecutionSystem();
const casts: SkillCastEvent[] = [];
const costs: AbilityCostPaidEvent[] = [];
EventBus.instance.subscribe<SkillCastEvent>('SkillCastEvent', (event) => casts.push(event));
EventBus.instance.subscribe<AbilityCostPaidEvent>('AbilityCostPaidEvent', (event) => costs.push(event));
skill.prepareCast({ caster, target });
caster.setHp(1);
EventBus.instance.publish<SkillPreCastEvent>({
type: 'SkillPreCastEvent', timestamp: Date.now(), caster, target,
fallbackTarget: target, ability: skill, isInterrupted: false,
});
system.destroy();
expect(casts[0].ability.id).toBe('basic_attack');
expect(casts[0].ability.tags.hasTag('Ability.Sect.Test.Technique')).toBe(false);
expect(costs).toHaveLength(1);
expect(costs[0]).toMatchObject({ hpPaid: 0, mpPaid: 0 });
expect(skill.currentCooldown).toBe(0);
expect(caster.getCurrentHp()).toBe(1);
});
it('冻结施法承诺时的变体,结算完才恢复实时解析', () => {
const caster = unit('caster');
const target = unit('target');
const skill = new DynamicDataDrivenActiveSkill('dynamic-test', '佛相式', {
cooldown: 0,
targetPolicy: TargetPolicy.default(),
variants: [
{
id: 'formless', name: '无相式', priority: 300,
conditions: [{ type: 'ability_mode_is', params: { scope: 'caster', key: 'form', mode: 'formless' } }],
costs: [{ resource: 'hp', mode: 'current_hp_ratio', ratio: 0.08, retain: 1 }],
targetPolicy: { team: 'self', scope: 'single' },
selectionProfile: { intents: ['defensive'] },
effects: [],
},
{
id: 'demon', name: '魔相式', priority: 200,
conditions: [{ type: 'ability_mode_is', params: { scope: 'caster', key: 'form', mode: 'demon' } }],
costs: [{ resource: 'hp', mode: 'current_hp_ratio', ratio: 0.05, retain: 1 }],
targetPolicy: { team: 'enemy', scope: 'single' },
selectionProfile: { intents: ['damage'] },
effects: [],
},
{ id: 'buddha', name: '佛相式', priority: 0, conditions: [], costs: [], effects: [] },
],
});
skill.setOwner(caster);
setAbilityMode(caster, { key: 'form', mode: 'formless', phase: 1, remainingUses: 1, displayName: '无相待发' });
skill.prepareCast({ caster, target });
setAbilityMode(caster, { key: 'form', mode: 'demon', phase: 1, remainingUses: 2, displayName: '魔相' });
expect(skill.name).toBe('无相式');
expect(skill.runtimeVariantId).toBe('formless');
expect(skill.targetPolicy.team).toBe('self');
expect(skill.selectionProfile?.intents).toEqual(['defensive']);
expect(skill.preparedTarget).toBe(target);
skill.execute({ caster, target });
expect(skill.name).toBe('魔相式');
expect(skill.targetPolicy.team).toBe('enemy');
});
it('形态只在技能效果成功结算时推进,未命中不会消费次数', () => {
const caster = unit('caster');
const target = unit('target');
const skill = AbilityFactory.create({
slug: 'mode-success', name: '形态推进', type: AbilityType.ACTIVE_SKILL,
tags: [GameplayTags.ABILITY.KIND.SKILL, GameplayTags.ABILITY.FUNCTION.BUFF],
effects: [{ type: 'ability_mode', params: { key: 'form', operation: 'advance' } }],
});
skill.setOwner(caster);
setAbilityMode(caster, { key: 'form', mode: 'demon', phase: 1, remainingUses: 2, displayName: '魔相' });
skill.execute({ caster, target, shouldApplyEffects: false });
expect(readAbilityMode(caster, 'form')).toMatchObject({ phase: 1, remainingUses: 2 });
skill.execute({ caster, target, shouldApplyEffects: true });
expect(readAbilityMode(caster, 'form')).toMatchObject({ phase: 2, remainingUses: 1 });
});
it('魔相多段直接伤害共享自身行动吸血上限', () => {
const caster = unit('caster');
const target = unit('target');
const maxHp = caster.getMaxHp();
caster.takeDamage(Math.round(maxHp * 0.5));
const before = caster.getCurrentHp();
const lifesteal = AbilityFactory.createEffect({
type: 'lifesteal',
params: { ratio: 0.25, maxHpRatioPerAction: 0.08 },
})!;
beginRuntimeAction(caster);
for (let index = 0; index < 3; index += 1) {
lifesteal.execute({
caster,
target,
triggerEvent: {
type: 'DamageTakenEvent', timestamp: Date.now(), caster, target,
damageSource: DamageSource.DIRECT, damageType: DamageType.PHYSICAL,
damageTaken: Math.round(maxHp * 0.2), beforeHp: target.getCurrentHp(),
remainHp: target.getCurrentHp(), isLethal: false,
},
});
}
expect(caster.getCurrentHp() - before).toBe(Math.round(maxHp * 0.08));
});
it('普通百分比减伤总和封顶70%', () => {
const caster = unit('caster');
const target = unit('target');
const system = new DamageSystem();
const before = target.getCurrentHp();
EventBus.instance.publish({
type: 'DamageRequestEvent', timestamp: Date.now(), target,
damageSource: DamageSource.DIRECT, damageType: DamageType.TRUE,
baseDamage: 1000, finalDamage: 1000, damageReductionPctBucket: 2,
});
const loss = before - target.getCurrentHp();
system.destroy();
expect(loss).toBeGreaterThanOrEqual(270);
expect(loss).toBeLessThanOrEqual(330);
});
});
@@ -0,0 +1,204 @@
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import type { ActiveSkill } from '../../abilities/ActiveSkill';
import { LayeredDataDrivenActiveSkill } from '../../abilities/LayeredDataDrivenActiveSkill';
import { StackRule } from '../../buffs/Buff';
import type { AbilityConfig, EffectConfig } from '../../core/configs';
import { EventBus } from '../../core/EventBus';
import { readAbilityMode, setAbilityMode } from '../../core/runtimeState';
import { AbilityType, AttributeType, BuffType } from '../../core/types';
import { AbilityFactory } from '../../factories/AbilityFactory';
import { GameplayTags } from '../../../shared/tag-domain';
import { Unit } from '../../units/Unit';
function unit(id: string): Unit {
return new Unit(id, id, {
[AttributeType.VITALITY]: 100,
[AttributeType.SPIRIT]: 10,
[AttributeType.WISDOM]: 10,
[AttributeType.SPEED]: 10,
[AttributeType.WILLPOWER]: 10,
});
}
function marker(id: string): EffectConfig {
return {
type: 'apply_buff',
params: {
target: 'caster',
buffConfig: {
id,
name: id,
type: BuffType.BUFF,
duration: 1,
stackRule: StackRule.OVERRIDE,
},
},
};
}
function layeredConfig(): AbilityConfig {
return {
slug: 'layered-test',
name: '佛相式',
description: 'A',
type: AbilityType.ACTIVE_SKILL,
tags: [GameplayTags.ABILITY.KIND.SKILL, GameplayTags.ABILITY.FUNCTION.BUFF],
costs: [{ resource: 'hp', mode: 'current_hp_ratio', ratio: 0.05, retain: 1 }],
cooldown: 2,
targetPolicy: { team: 'enemy', scope: 'single' },
selectionProfile: { intents: ['buff'] },
effects: [marker('a-main')],
completionEffects: [marker('a-done')],
effectLayers: [
{ id: 'demon', effects: [marker('b-main')], completionEffects: [marker('b-done')] },
{ id: 'formless', effects: [marker('c-main')], completionEffects: [marker('c-done')] },
],
effectPlans: [
{
id: 'formless', name: '无相式', description: 'A+B+C', priority: 300,
conditions: [{ type: 'ability_mode_is', params: { scope: 'caster', key: 'form', mode: 'formless' } }],
layerIds: ['demon', 'formless'], consumeModeKey: 'form',
},
{
id: 'demon', name: '魔相式', description: 'A+B', priority: 200,
conditions: [{ type: 'ability_mode_is', params: { scope: 'caster', key: 'form', mode: 'demon' } }],
layerIds: ['demon'], consumeModeKey: 'form',
},
],
};
}
describe('技能效果层与计划', () => {
beforeEach(() => EventBus.instance.reset());
afterEach(() => EventBus.instance.reset());
it.each([
['buddha', undefined, ['a-main', 'a-done']],
['demon', 'demon', ['a-main', 'b-main', 'a-done', 'b-done']],
['formless', 'formless', ['a-main', 'b-main', 'c-main', 'a-done', 'b-done', 'c-done']],
] as const)('%s 依次执行固定 A、追加层和完成效果', (_label, mode, expected) => {
const caster = unit('caster');
const target = unit('target');
const skill = AbilityFactory.create(layeredConfig()) as ActiveSkill;
skill.setOwner(caster);
if (mode) {
setAbilityMode(caster, {
key: 'form', mode, remainingUses: mode === 'demon' ? 2 : 1,
displayName: mode,
});
}
skill.execute({ caster, target });
expect(caster.buffs.getAllBuffIds()).toEqual(expected);
});
it('优先级选中无相计划,且三相共享目标、费用、冷却和选招意图', () => {
const caster = unit('caster');
const target = unit('target');
const skill = AbilityFactory.create(layeredConfig()) as LayeredDataDrivenActiveSkill;
skill.setOwner(caster);
setAbilityMode(caster, {
key: 'form', mode: 'formless', remainingUses: 1, displayName: '无相',
});
expect(skill.name).toBe('无相式');
expect(skill.runtimePlanId).toBe('formless');
expect(skill.targetPolicy).toMatchObject({ team: 'enemy', scope: 'single' });
expect(skill.costConfigs).toEqual(layeredConfig().costs);
expect(skill.maxCooldown).toBe(2);
expect(skill.selectionProfile).toEqual({ intents: ['buff'] });
skill.execute({ caster, target });
expect(readAbilityMode(caster, 'form')).toBeUndefined();
});
it('施法准备后冻结计划,clone 保留配置但不复制准备态', () => {
const caster = unit('caster');
const target = unit('target');
const skill = AbilityFactory.create(layeredConfig()) as LayeredDataDrivenActiveSkill;
skill.setOwner(caster);
setAbilityMode(caster, {
key: 'form', mode: 'formless', remainingUses: 1, displayName: '无相',
});
skill.prepareCast({ caster, target });
setAbilityMode(caster, {
key: 'form', mode: 'demon', remainingUses: 2, displayName: '魔相',
});
const cloneOwner = unit('clone-owner');
const clone = skill.clone();
clone.setOwner(cloneOwner);
expect(skill.name).toBe('无相式');
expect(skill.runtimePlanId).toBe('formless');
expect(clone.name).toBe('佛相式');
expect(clone.runtimePlanId).toBeUndefined();
skill.execute({ caster, target });
expect(caster.buffs.getAllBuffIds()).toEqual([
'a-main', 'b-main', 'c-main', 'a-done', 'b-done', 'c-done',
]);
expect(readAbilityMode(caster, 'form')).toMatchObject({ mode: 'demon', remainingUses: 1 });
});
it('未命中不执行主效果、完成效果或消费 mode', () => {
const caster = unit('caster');
const target = unit('target');
const skill = AbilityFactory.create(layeredConfig()) as ActiveSkill;
skill.setOwner(caster);
setAbilityMode(caster, {
key: 'form', mode: 'demon', remainingUses: 2, displayName: '魔相',
});
skill.execute({ caster, target, shouldApplyEffects: false });
expect(caster.buffs.getAllBuffIds()).toEqual([]);
expect(readAbilityMode(caster, 'form')).toMatchObject({ remainingUses: 2 });
});
it('主效果合法 no-op 时仍执行完成效果并消费 mode', () => {
const caster = unit('caster');
const target = unit('target');
const config = layeredConfig();
config.effects = [{ type: 'dispel', params: { targetTag: 'missing', maxCount: 1 } }];
config.completionEffects = [marker('completed')];
config.effectLayers = [{ id: 'demon' }];
config.effectPlans = [config.effectPlans![1]];
const skill = AbilityFactory.create(config) as ActiveSkill;
skill.setOwner(caster);
setAbilityMode(caster, {
key: 'form', mode: 'demon', remainingUses: 2, displayName: '魔相',
});
skill.execute({ caster, target });
expect(caster.buffs.getAllBuffIds()).toEqual(['completed']);
expect(readAbilityMode(caster, 'form')).toMatchObject({ remainingUses: 1 });
});
it.each([
['duplicate layer', { effectLayers: [{ id: 'same' }, { id: 'same' }] }],
['duplicate plan', { effectPlans: [
{ id: 'same', name: 'one', priority: 2, conditions: [], layerIds: [] },
{ id: 'same', name: 'two', priority: 1, conditions: [], layerIds: [] },
] }],
['unknown layer', { effectPlans: [
{ id: 'plan', name: 'plan', priority: 1, conditions: [], layerIds: ['missing'] },
] }],
['duplicate reference', {
effectLayers: [{ id: 'layer' }],
effectPlans: [{ id: 'plan', name: 'plan', priority: 1, conditions: [], layerIds: ['layer', 'layer'] }],
}],
['plan override', {
effectPlans: [{
id: 'plan', name: 'plan', priority: 1, conditions: [], layerIds: [],
costs: [{ resource: 'hp', mode: 'flat', amount: 1 }],
targetPolicy: { team: 'self', scope: 'single' },
selectionProfile: { intents: ['damage'] },
cooldown: 0,
tags: [],
}],
}],
] as const)('工厂拒绝 %s', (_label, override) => {
expect(() => AbilityFactory.create({ ...layeredConfig(), ...override })).toThrow();
});
});
@@ -1,5 +1,5 @@
import { StandardSectModule, type SectBuildBuilder, type SectProjectionContext } from '../../core';
import { compileWuxiangBase } from './compiler';
import { compileWuxiangBase } from './shared/compiler';
import { WUXIANG_BASE_DEFINITION } from './definition';
import { WUXIANG_ORGANIZATION_THEME } from './organization';
import { WUXIANG_DEMON_PATH_MODULE, WUXIANG_MIRROR_PATH_MODULE } from './paths';
File diff suppressed because it is too large. Load diff
@@ -17,7 +17,7 @@ export const WUXIANG_BASE_DEFINITION: SectDefinitionWithoutPaths = {
description: '在佛灯与血池之间守住一念,胜负皆记作完成。',
},
raceIds: ['human'],
configVersion: 1,
configVersion: 2,
combatResource: {
id: WUXIANG_WAR_INTENT,
name: '战意',
@@ -62,13 +62,13 @@ export const WUXIANG_BASE_DEFINITION: SectDefinitionWithoutPaths = {
},
],
abilities: [
{ id: 'flower-heart', kind: 'default', baseName: '拈花叩心', description: '指间拈花,叩问的却是敌我同一颗心。', role: 'generator', unlock: { type: 'method', methodId: 'wuxiang-canon', level: 1 }, cooldown: 0 },
{ id: 'blood-tide', kind: 'active', baseName: '血海听潮', description: '不拒血海来潮,先听清每一道苦声从何处生。', role: 'defensive', unlock: { type: 'method', methodId: 'blood-lotus', level: 1 }, cooldown: 3 },
{ id: 'three-knocks', kind: 'active', baseName: '三叩业门', description: '一叩问因,二叩问果,三叩时门内门外再无旁人。', role: 'combo', unlock: { type: 'method', methodId: 'white-bone', level: 3 }, cooldown: 2 },
{ id: 'observe-calamity', kind: 'active', baseName: '闭目观劫', description: '闭目并非不见,而是不被劫相夺去心神。', role: 'defensive', unlock: { type: 'method', methodId: 'wrathful-ming', level: 3 }, cooldown: 4 },
{ id: 'five-skandhas', kind: 'active', baseName: '照见五蕴', description: '色受想行识逐一照破,外法与我执皆无所藏。', role: 'utility', unlock: { type: 'method', methodId: 'six-senses', level: 3 }, cooldown: 3 },
{ id: 'reed-crossing', kind: 'active', baseName: '一苇横江', description: '江阔浪急,脚下只留一苇,心中却已有彼岸。', role: 'defensive', unlock: { type: 'method', methodId: 'reed-crossing-method', level: 3 }, cooldown: 5 },
{ id: 'turn-form', kind: 'active', baseName: '一念未生', description: '战意未足时,一念尚伏于心;战意既成,佛魔只在翻掌之间。', role: 'finisher', unlock: { type: 'method', methodId: 'wuxiang-canon', level: 5 }, cooldown: 0 },
{ id: 'flower-heart', kind: 'default', baseName: '拈花叩心', description: '指间拈花,叩问的却是敌我同一颗心。佛相立其因,魔相照其果,无相令因果同现。', role: 'generator', unlock: { type: 'method', methodId: 'wuxiang-canon', level: 1 }, cooldown: 0 },
{ id: 'blood-tide', kind: 'active', baseName: '血海听潮', description: '不拒血海来潮,先听清每一道苦声从何处生,再于回澜时借势渡身。', role: 'defensive', unlock: { type: 'method', methodId: 'blood-lotus', level: 1 }, cooldown: 3 },
{ id: 'three-knocks', kind: 'active', baseName: '三叩业门', description: '一叩问因,二叩问果,三叩之后,门内门外皆由一念开合。', role: 'combo', unlock: { type: 'method', methodId: 'white-bone', level: 3 }, cooldown: 2 },
{ id: 'observe-calamity', kind: 'active', baseName: '闭目观劫', description: '闭目并非不见,而是不被劫相夺去心神;开眼之时,劫已照明。', role: 'defensive', unlock: { type: 'method', methodId: 'wrathful-ming', level: 3 }, cooldown: 4 },
{ id: 'five-skandhas', kind: 'active', baseName: '照见五蕴', description: '色受想行识逐一照破。佛相辨其虚实,魔相借火自渡,无相令诸蕴俱空。', role: 'utility', unlock: { type: 'method', methodId: 'six-senses', level: 3 }, cooldown: 3 },
{ id: 'reed-crossing', kind: 'active', baseName: '一苇横江', description: '江阔浪急,脚下只留一苇;佛相守住此岸,魔相强渡彼岸,无相则知两岸非岸。', role: 'defensive', unlock: { type: 'method', methodId: 'reed-crossing-method', level: 3 }, cooldown: 5 },
{ id: 'turn-form', kind: 'active', baseName: '一念未生', description: '战意未足时,一念尚伏于心;战意既成,佛、魔与无相只在翻掌之间。', role: 'finisher', unlock: { type: 'method', methodId: 'wuxiang-canon', level: 5 }, cooldown: 0 },
{ id: 'mirror-core', kind: 'passive', baseName: '明镜照业', description: '来力皆留其痕,因满果熟时照还来处。', role: 'defensive', unlock: { type: 'active_path', pathId: WUXIANG_MIRROR_PATH_ID }, visibility: 'internal' },
{ id: 'demon-core', kind: 'passive', baseName: '魔心渡厄', description: '以气血作舟、战意作楫,于一息将尽之际横渡生死。', role: 'combo', unlock: { type: 'active_path', pathId: WUXIANG_DEMON_PATH_ID }, visibility: 'internal' },
],
@@ -2,7 +2,6 @@ export const WUXIANG_SECT_ID = 'wuxiang';
export const WUXIANG_WAR_INTENT = 'sect.wuxiang.war-intent';
export const WUXIANG_FORM_MODE = 'sect.wuxiang.form';
export const WUXIANG_KARMA_BUFF = 'sect.wuxiang.karma-mark';
export const WUXIANG_BLOOD_TIDE_MEMORY = 'sect.wuxiang.blood-tide';
export const WUXIANG_MIRROR_PATH_ID = 'mirror-karma';
export const WUXIANG_DEMON_PATH_ID = 'demon-crossing';
@@ -1,6 +1,7 @@
export * from './definition';
export * from './ids';
export * from './nodes';
export * from './paths/demon/nodes';
export * from './paths/mirror/nodes';
export * from './organization';
export * from './paths';
export * from './presentation';
@@ -1,61 +0,0 @@
import {
ConfiguredSectNodePlugin,
type SectMeridianNodeDefinition,
} from '../../core';
import { WUXIANG_FEATURES, type WuxiangFeatures } from './compiler';
type NodeSpec = SectMeridianNodeDefinition & { feature: string };
function node(spec: NodeSpec): ConfiguredSectNodePlugin {
const { feature, ...definition } = spec;
return new ConfiguredSectNodePlugin(definition, (_context, builder) => {
builder
.requireExtension<WuxiangFeatures>(WUXIANG_FEATURES, '无相道途循环规则')
.add(feature);
});
}
const mirrorSpecs: NodeSpec[] = [
{ id: 'mirror-vow-body', feature: 'mirror-vow-body', layerId: '1', name: '戒由身起', description: '每轮首次佛相气血成本提高2%,并额外获得1点战意。' },
{ id: 'mirror-guest-in-mirror', feature: 'mirror-guest-in-mirror', layerId: '1', name: '镜中留客', description: '每轮首次受到敌方直接伤害时,额外留下1层业痕。' },
{ id: 'mirror-fruit-in-time', feature: 'mirror-fruit-in-time', layerId: '1', name: '果不逾时', description: '同一敌方行动的宗门反伤上限由最大气血12%提高至16%。' },
{ id: 'mirror-loud-flower', feature: 'mirror-loud-flower', layerId: '2', name: '花落有声', description: '叩心戒的伤害衰减由10%提高至18%。' },
{ id: 'mirror-welcome-tide', feature: 'mirror-welcome-tide', layerId: '2', name: '潮来不拒', description: '血海听潮延后的直接伤害由30%提高至40%。' },
{ id: 'mirror-fourth-knock', feature: 'mirror-fourth-knock', layerId: '2', name: '门叩第四声', description: '三叩业门额外留下第四扇业门。' },
{ id: 'mirror-see-guest', feature: 'mirror-see-guest', layerId: '3', name: '闭目见客', description: '闭目观劫首次直接伤害减免由35%提高至50%。' },
{ id: 'mirror-skandhas-mark', feature: 'mirror-skandhas-mark', layerId: '3', name: '蕴去痕留', description: '照见五蕴成功化去增益时改为产生2层业痕。' },
{ id: 'mirror-carry-karma', feature: 'mirror-carry-karma', layerId: '3', name: '一苇载业', description: '一苇横江的单次直接受击上限由最大气血30%降至25%。' },
{ id: 'mirror-form-beyond', feature: 'mirror-form-beyond', layerId: '4', name: '相外有相', description: '魔相入身后的止观减伤由25%提高至35%。' },
{ id: 'mirror-back-demon', feature: 'mirror-back-demon', layerId: '4', name: '镜背生魔', description: '每次魔相第一门神通免费执行现报条款,不消耗业痕。' },
{ id: 'mirror-formless-two', feature: 'mirror-formless-two', layerId: '4', name: '一念两照', description: '无相式结算后留下2层业痕,但气血成本增加2%。' },
{ id: 'mirror-full-light', feature: 'mirror-full-light', layerId: '5', name: '业满成光', description: '业痕满层时,佛相即时反伤额外提高5%。' },
{ id: 'mirror-fast-fruit', feature: 'mirror-fast-fruit', layerId: '5', name: '现报无迟', description: '所有现报伤害系数提高0.20。' },
{ id: 'mirror-return-source', feature: 'mirror-return-source', layerId: '5', name: '照还来处', description: '每次消耗业痕恢复2%最大气血。' },
{ id: 'mirror-not-platform', feature: 'mirror-not-platform', layerId: 'ultimate', name: '明镜非台', description: '佛相且业痕满层时,受到的直接伤害降低10%。' },
{ id: 'mirror-all-karma', feature: 'mirror-all-karma', layerId: 'ultimate', name: '万业同门', description: '魔相现报可一次消耗至多2层业痕,并按消耗层数强化效果。' },
{ id: 'mirror-return-thought', feature: 'mirror-return-thought', layerId: 'ultimate', name: '来去一念', description: '无相结算后返还2点战意;下一门佛相神通成本提高3%。' },
];
const demonSpecs: NodeSpec[] = [
{ id: 'demon-blood-oil', feature: 'demon-blood-oil', layerId: '1', name: '血作灯油', description: '每轮首次佛相气血成本提高2%,并额外获得1点战意。' },
{ id: 'demon-three-shores', feature: 'demon-three-shores', layerId: '1', name: '三岸留痕', description: '首次跨越70%、45%、25%气血线时,分别获得2%最大气血护盾。' },
{ id: 'demon-bone-tide', feature: 'demon-bone-tide', layerId: '1', name: '潮伏骨中', description: '血潮储存上限由15%提高至22%最大气血。' },
{ id: 'demon-flower-inward', feature: 'demon-flower-inward', layerId: '2', name: '花开向内', description: '心隙令下一次宗门直接伤害提高25%。' },
{ id: 'demon-no-return-tide', feature: 'demon-no-return-tide', layerId: '2', name: '潮不回头', description: '血潮转为伤害的倍率由2倍提高至2.5倍。' },
{ id: 'demon-third-outside', feature: 'demon-third-outside', layerId: '2', name: '门外三声', description: '三叩业门强化第三击的气血线由45%提高至55%。' },
{ id: 'demon-slow-fire', feature: 'demon-slow-fire', layerId: '3', name: '劫火缓行', description: '闭目观劫延后的直接伤害由40%提高至50%。' },
{ id: 'demon-skandhas-fuel', feature: 'demon-skandhas-fuel', layerId: '3', name: '五蕴作薪', description: '焚尽五蕴可焚烧的负面状态上限提高至3个。' },
{ id: 'demon-short-reed', feature: 'demon-short-reed', layerId: '3', name: '苇短水长', description: '一苇横江的单次直接受击上限由最大气血35%降至30%。' },
{ id: 'demon-first-thought', feature: 'demon-first-thought', layerId: '4', name: '第一念魔', description: '所有入魔条款强度提高15%。' },
{ id: 'demon-second-shore', feature: 'demon-second-shore', layerId: '4', name: '第二岸苦', description: '所有渡厄条款强度提高20%。' },
{ id: 'demon-two-gates', feature: 'demon-two-gates', layerId: '4', name: '两门同渡', description: '两次魔相使用不同神通时,第二门气血成本减半。' },
{ id: 'demon-body-breaks', feature: 'demon-body-breaks', layerId: '5', name: '身坏心明', description: '佛相低于30%气血时获得30%控制抗性。' },
{ id: 'demon-blood-empty', feature: 'demon-blood-empty', layerId: '5', name: '血尽潮生', description: '首次由自身成本跨越25%气血线时,额外获得6%最大气血护盾。' },
{ id: 'demon-leave-boat', feature: 'demon-leave-boat', layerId: '5', name: '渡后留舟', description: '魔相结束后下一门佛相神通成本减半,但不产生战意。' },
{ id: 'demon-one-furnace', feature: 'demon-one-furnace', layerId: 'ultimate', name: '佛魔同炉', description: '无相式的入魔、渡厄条款由80%提高至完整强度。' },
{ id: 'demon-no-gap', feature: 'demon-no-gap', layerId: 'ultimate', name: '一息无间', description: '魔相吸血单行动上限提高至12%,但失去转相减伤。' },
{ id: 'demon-look-back', feature: 'demon-look-back', layerId: 'ultimate', name: '回首彼岸', description: '渡厄结算后若存活且低于20%气血,恢复5%最大气血;每次转相一次。' },
];
export const WUXIANG_MIRROR_NODES = mirrorSpecs.map(node);
export const WUXIANG_DEMON_NODES = demonSpecs.map(node);
+51 -42
View File
@@ -6,35 +6,43 @@ import {
type SectPathDefinitionWithoutNodes,
type SectTacticId,
} from '../../core';
import { compileWuxiangPath, WUXIANG_FEATURES, type WuxiangFeatures } from './compiler';
import { compileWuxiangPath } from './shared/compiler';
import { WUXIANG_DEMON_PATH_ID, WUXIANG_MIRROR_PATH_ID } from './ids';
import { WUXIANG_DEMON_NODES, WUXIANG_MIRROR_NODES } from './nodes';
import { WUXIANG_DEMON_NODES } from './paths/demon/nodes';
import { WUXIANG_MIRROR_NODES } from './paths/mirror/nodes';
import {
createWuxiangBuildSettings,
DEMON_BUILD_FACADE,
DemonBuildFacade,
MIRROR_BUILD_FACADE,
MirrorBuildFacade,
} from './shared/buildFacades';
import { WuxiangDemonSelectionStrategy, WuxiangMirrorSelectionStrategy } from './strategy';
const mirrorDefinition: SectPathDefinitionWithoutNodes = {
id: WUXIANG_MIRROR_PATH_ID,
name: '明镜照业',
description: '以承受制造因,以魔相兑现果。来力不急于拒绝,只把每一道因果照还原处。',
description: '以承受制造因,以魔相兑现果。来力不急于拒绝,只把每一道因果留在镜中;佛相立因,魔相现报,无相令因果同时照见。',
minRealm: '筑基', minRealmStage: '初期', layers: [...STANDARD_PATH_LAYERS], defaultTacticId: 'guard',
tactics: [
{ id: 'guard', name: '守镜', description: '优先保持3层业痕与防守状态,5点战意后入魔。' },
{ id: 'present', name: '现报', description: '至少1层业痕且达到3点战意便入魔,优先引爆业门与转还状态。' },
{ id: 'formless', name: '无相', description: '原则上积满6点战意;低于35%气血时允许提前入魔自救。' },
{ id: 'guard', name: '守镜', description: '优先保持3层业痕与防守状态;达到5点战意后入魔,优先使用防御神通维持血线。' },
{ id: 'present', name: '现报', description: '至少1层业痕且达到3点战意便入魔,优先使用可即时消费业痕的攻击神通。' },
{ id: 'formless', name: '无相', description: '原则上积满6点战意再显无相;低于35%气血且已有3点战意时允许提前入魔自救。' },
],
presentation: {
highlights: [
{ name: '受击留业', description: '敌方直接伤害留下业痕,并在佛相即时反伤。' },
{ name: '现报兑现', description: '两次魔相神通消费业痕,改变技能的战术功能。' },
{ name: '一念两照', description: '无相式同时设局与现报,并把业痕重置为可继续周转的起点。' },
{ name: '受击留业', description: '佛相承受敌方直接伤害,积累业痕并即时返还部分来力。' },
{ name: '逐层现报', description: '每门神通保留佛相主体,魔相追加该技能专属现报,无相再追加最终变化。' },
{ name: '一念两照', description: '无相不是替换技能,而是让同一门神通的佛、魔、无相三层同时结算。' },
],
abilityChanges: {
'flower-heart': '佛相立戒,魔相封招,无相兼施两者。',
'blood-tide': '佛相延伤,魔相回澜,无相同时延伤与返还。',
'three-knocks': '佛相留下业门,魔相引爆余门。',
'observe-calamity': '佛相架势承劫,魔相按受击次数见劫。',
'five-skandhas': '在增益、减益与业痕之间进行状态交换。',
'reed-crossing': '分别提供伤害上限、伤害转还与二者合一。',
'turn-form': '3至5战意进入两式魔相,6战意令下一门神通化为无相。',
'flower-heart': '佛相立下叩心戒;魔相消费业痕追加问罪与封招;无相再添一击并重新留下业痕。',
'blood-tide': '佛相提供护盾与听潮减伤;魔相消费业痕恢复并登记受击反击;无相额外加厚护盾。',
'three-knocks': '佛相三击并在结算末尾留下新业门;魔相消费业痕并引爆此前的旧业门;无相再追加一记无相之击。',
'observe-calamity': '佛相架势抵御一次直接伤害;魔相消费业痕令减伤同时反击;无相将架势扩展为两次。',
'five-skandhas': '佛相伤害并驱散敌方增益;魔相消费业痕净化自身;无相追加伤害与护盾。',
'reed-crossing': '佛相保护下一次直接受击;魔相消费业痕令防护触发时反击;无相额外获得护盾。',
'turn-form': '3~5战意令之后两门神通结算A+B;6战意令下一门神通结算A+B+C。',
},
},
};
@@ -42,51 +50,52 @@ const mirrorDefinition: SectPathDefinitionWithoutNodes = {
const demonDefinition: SectPathDefinitionWithoutNodes = {
id: WUXIANG_DEMON_PATH_ID,
name: '魔心渡厄',
description: '以佛相主动沉血,以两次魔相完成连招。气血不是怒气的别名,而是必须偿还的渡河之资。',
description: '以佛相主动沉血,以魔相强渡两息,以无相令燃血与渡厄同时显化。气血不是怒气的别名,而是每一门神通真正支付的渡河之资。',
minRealm: '筑基', minRealmStage: '初期', layers: [...STANDARD_PATH_LAYERS], defaultTacticId: 'trial-fire',
tactics: [
{ id: 'trial-fire', name: '试火', description: '3点战意且低于60%气血入魔,优先延伤与保命。' },
{ id: 'sink-boat', name: '沉舟', description: '积至5点战意并主动压到45%以下,以血潮、三叩收束。' },
{ id: 'one-thought', name: '一念', description: '优先积满6点战意使用无相;低于25%时允许提前入魔。' },
{ id: 'trial-fire', name: '试火', description: '达到3点战意且低于60%气血时入魔,优先防御神通与吸血窗口,避免继续无保护沉血。' },
{ id: 'sink-boat', name: '沉舟', description: '尽量积至5点战意并主动压至45%气血以下,入魔后优先连续使用攻击神通收束。' },
{ id: 'one-thought', name: '一念', description: '优先积满6点战意使用无相;低于25%气血时允许以3点战意提前入魔自救。' },
],
presentation: {
highlights: [
{ name: '主动沉血', description: '佛相支付气血并跨越三条血线,以此加速战意循环。' },
{ name: '两式成招', description: '第一门生成魔契,第二门消费魔契完成不同技能间的连招。' },
{ name: '短窗求生', description: '霸体、减伤与吸血只存在于转相窗口,必须用两次行动解决局面。' },
{ name: '主动沉血', description: '佛相支付当前气血施展神通,以真实代价换取战意。' },
{ name: '两息渡厄', description: '魔相连续两门神通均在佛相主体上追加各自B层,并共享减伤、免控和吸血窗口。' },
{ name: '佛魔同炉', description: '无相令同一技能的A、B、C三层一次结算,而不是重新生成另一门技能。' },
],
abilityChanges: {
'flower-heart': '留下心隙,再以渡厄摘心收束。',
'blood-tide': '佛相储存实际气血成本,魔相把血潮转为伤害。',
'three-knocks': '随自身低血强化第三叩,渡厄式可强制暴击。',
'observe-calamity': '延期债务不会消失,但可在渡厄时照向目标。',
'five-skandhas': '负面状态既可移交,也可作为焚烧增伤的薪柴。',
'reed-crossing': '佛相控制单次受击,魔相在濒危时兼具收束与回血。',
'turn-form': '3至5战意开启两段连招,6战意把入魔与渡厄压入一次行动。',
'flower-heart': '佛相伤敌并留下心隙;魔相追加摘心一击与第二层心隙;无相按目标已损气血收束。',
'blood-tide': '佛相重血换取护盾与下一击强化;魔相加厚护盾并令血潮命中回血;无相立即回生并强化血潮。',
'three-knocks': '佛相三击并在低血强化第三击;魔相追加第四击;无相在濒危时追加必定暴击的无生一击。',
'observe-calamity': '佛相降低下一次直接伤害;魔相令承劫后反击;无相再获得护盾。',
'five-skandhas': '佛相净化并在成功时获得战意;魔相获得护盾与下一击强化;无相再净化一个减益。',
'reed-crossing': '佛相获得护盾与下一击减伤;魔相进一步强化两者;无相在濒危时恢复气血。',
'turn-form': '3~5战意令之后两门神通结算A+B并获得渡厄护体;6战意令下一门神通结算A+B+C。',
},
},
};
abstract class WuxiangPathModule extends BaseSectPathModule {
class MirrorPathModule extends BaseSectPathModule {
constructor() { super(mirrorDefinition, WUXIANG_MIRROR_NODES); }
protected initializeBuild(_context: SectPathCompileContext, builder: SectBuildBuilder): void {
builder.setExtension<WuxiangFeatures>(WUXIANG_FEATURES, new Set());
builder.setExtension(MIRROR_BUILD_FACADE, new MirrorBuildFacade(createWuxiangBuildSettings()));
}
protected finalizeBuild(context: SectPathCompileContext, builder: SectBuildBuilder): void {
compileWuxiangPath(
builder,
context.path.pathId,
builder.requireExtension<WuxiangFeatures>(WUXIANG_FEATURES, '无相道途循环规则'),
);
const facade = builder.requireExtension<MirrorBuildFacade>(MIRROR_BUILD_FACADE, '明镜照业构筑');
compileWuxiangPath(builder, context.path.pathId, facade.settings);
}
}
class MirrorPathModule extends WuxiangPathModule {
constructor() { super(mirrorDefinition, WUXIANG_MIRROR_NODES); }
createSelectionStrategy(tacticId: SectTacticId) { return new WuxiangMirrorSelectionStrategy(tacticId); }
}
class DemonPathModule extends WuxiangPathModule {
class DemonPathModule extends BaseSectPathModule {
constructor() { super(demonDefinition, WUXIANG_DEMON_NODES); }
protected initializeBuild(_context: SectPathCompileContext, builder: SectBuildBuilder): void {
builder.setExtension(DEMON_BUILD_FACADE, new DemonBuildFacade(createWuxiangBuildSettings()));
}
protected finalizeBuild(context: SectPathCompileContext, builder: SectBuildBuilder): void {
const facade = builder.requireExtension<DemonBuildFacade>(DEMON_BUILD_FACADE, '魔心渡厄构筑');
compileWuxiangPath(builder, context.path.pathId, facade.settings);
}
createSelectionStrategy(tacticId: SectTacticId) { return new WuxiangDemonSelectionStrategy(tacticId); }
}
@@ -0,0 +1,231 @@
import type { ConditionConfig, EffectConfig, ListenerConfig } from '@shared/engine/battle-v5/core/configs';
import { DamageSource } from '@shared/engine/battle-v5/core/types';
import { GameplayTags } from '@shared/engine/shared/tag-domain';
import { SectAbilityFactory, type SectCompiledAbility } from '../../../../core';
import type { WuxiangBuildSettings } from '../../shared/buildFacades';
import type { WuxiangCompilerApi } from '../../shared/compiler';
export function compileDemonAbilities(
features: WuxiangBuildSettings,
api: WuxiangCompilerApi,
): Record<string, SectCompiledAbility> {
const {
WUXIANG_DEMON_PATH_ID, buff, clearBuff, demonCompletion, directGuard,
firstThoughtBonus, gainWar, heal, heartGap, layeredAbility, outgoingBoost,
physical, selfBuff, shield,
} = api;
const bDone = () => demonCompletion(features, 'demon');
const cDone = () => demonCompletion(features, 'formless');
const first = (target: 'enemy' | 'self'): EffectConfig[] =>
features.demonFirstThought ? [firstThoughtBonus(target)] : [];
const heartBonus = features.demonHeartGapBonus;
const flower = layeredAbility({
id: 'flower-heart', pathId: WUXIANG_DEMON_PATH_ID, cost: 0.06, target: 'enemy', features,
effects: [physical(0.6)],
completionEffects: [heartGap(heartBonus)],
demonName: '摘心问魔',
demonDescription: '佛相叩心仍然结算;再以魔相摘心追加一击,并在技能结束后多留一层心隙。',
demonEffects: [...first('enemy'), physical(0.35)],
demonCompletionEffects: [heartGap(heartBonus), ...bDone()],
formlessName: '心魔两忘',
formlessDescription: '叩心与摘心同时显化,再按目标已损气血完成收束;技能结束后仍留下两层心隙。',
formlessEffects: [physical(0, undefined, {
dynamicMissingHpCap: features.demonOneFurnace ? 0.6 : 0.4,
})],
formlessCompletionEffects: cDone(),
});
const tideBaseBonus = features.demonTideDamageBonus;
const tideShield = features.demonTideShieldRatio;
const blood = layeredAbility({
id: 'blood-tide', pathId: WUXIANG_DEMON_PATH_ID, cost: 0.14, target: 'self', features,
effects: [shield(tideShield), outgoingBoost('sect.wuxiang.demon.blood-tide-boost', '血潮', tideBaseBonus)],
demonName: '血海倒悬',
demonDescription: '佛相血潮仍然结算;魔相令血海倒悬,护盾加厚,并在血潮命中时回收一部分气血。',
demonEffects: [
...first('self'),
shield(0.05),
clearBuff('sect.wuxiang.demon.blood-tide-boost'),
outgoingBoost('sect.wuxiang.demon.blood-tide-boost', '血潮', tideBaseBonus, { healOnHit: 0.03 }),
],
demonCompletionEffects: bDone(),
formlessName: '血海无涯',
formlessDescription: '血海无涯,沉血与回生同时发生;恢复气血,并令下一门直接伤害获得更强血潮。',
formlessEffects: [
heal(features.demonOneFurnace ? 0.08 : 0.05),
clearBuff('sect.wuxiang.demon.blood-tide-boost'),
outgoingBoost('sect.wuxiang.demon.blood-tide-boost', '血海无涯', 0.3, { healOnHit: 0.03 }),
],
formlessCompletionEffects: cDone(),
});
const lowThreshold = features.demonThirdHitThreshold;
const lowCondition: ConditionConfig[] = [{
type: 'hp_below', params: { scope: 'caster', value: lowThreshold, timing: 'cast' },
}];
const knocks = layeredAbility({
id: 'three-knocks', pathId: WUXIANG_DEMON_PATH_ID, cost: 0.09, target: 'enemy', features,
effects: [physical(0.25), physical(0.25), physical(0.25), physical(0.25, lowCondition)],
demonName: '三叩魔关',
demonDescription: '佛相三叩仍然结算;魔相再叩一门,以第四击强渡魔关。',
demonEffects: [...first('enemy'), physical(0.45)],
demonCompletionEffects: bDone(),
formlessName: '业门无生',
formlessDescription: '三叩与魔关一并显化;濒危时再追加一次必定暴击的无生之击。',
formlessEffects: [physical(
features.demonOneFurnace ? 0.85 : 0.65,
[{ type: 'hp_below', params: { scope: 'caster', value: 0.35, timing: 'cast' } }],
{ forceCritical: true },
)],
formlessCompletionEffects: cDone(),
});
const observe = layeredAbility({
id: 'observe-calamity', pathId: WUXIANG_DEMON_PATH_ID, cost: 0.11, target: 'self', features,
effects: [directGuard(
'sect.wuxiang.demon.observe',
'承劫',
features.demonObserveReduction,
{ counter: 0.4, counterMarker: 'sect.wuxiang.demon.observe-counter' },
)],
demonName: '开眼见魔',
demonDescription: '佛相承劫仍然结算;开眼见魔,承受来力之后立即反击。',
demonEffects: [
...first('self'),
selfBuff(buff('sect.wuxiang.demon.observe-counter', '开眼见魔', 1, [], { dispelPolicy: 'protected' })),
],
demonCompletionEffects: bDone(),
formlessName: '劫火自明',
formlessDescription: '劫火不再成为债务,而在当下照明自身;获得护盾,并同时保留承劫与反击。',
formlessEffects: [shield(features.demonOneFurnace ? 0.12 : 0.08)],
formlessCompletionEffects: cDone(),
});
const cleanseCount = features.demonCleanseCount;
const skandhas = layeredAbility({
id: 'five-skandhas', pathId: WUXIANG_DEMON_PATH_ID, cost: 0.07, target: 'self', features,
effects: [{
type: 'dispel',
params: { recipient: 'caster', status: 'negative', maxCount: cleanseCount, effects: [gainWar()] },
}],
demonName: '焚尽五蕴',
demonDescription: '佛相净化仍然结算;魔相以五蕴作薪,获得护盾并强化下一门宗门直接伤害。',
demonEffects: [
...first('self'),
shield(0.06),
outgoingBoost('sect.wuxiang.demon.skandhas-boost', '焚尽五蕴', 0.2),
],
demonCompletionEffects: bDone(),
formlessName: '蕴空身在',
formlessDescription: '净化与焚尽同时结算;再移除一个减益,诸蕴虽空,色身仍在。',
formlessEffects: [
{ type: 'dispel', params: { recipient: 'caster', status: 'negative', maxCount: 1 } },
...(features.demonOneFurnace ? [heal(0.05)] : []),
],
formlessCompletionEffects: cDone(),
});
const baseReedReduction = features.demonReedReduction;
const reed = layeredAbility({
id: 'reed-crossing', pathId: WUXIANG_DEMON_PATH_ID, cost: 0.1, target: 'self', features,
effects: [shield(0.1), directGuard('sect.wuxiang.demon.reed', '血舟', baseReedReduction)],
demonName: '一苇渡厄',
demonDescription: '佛相血舟仍然结算;魔相强渡,使护盾更厚、下一次直接伤害减免更高。',
demonEffects: [
...first('self'),
shield(0.05),
directGuard(
'sect.wuxiang.demon.reed-extra',
'一苇渡厄',
0.1,
),
],
demonCompletionEffects: bDone(),
formlessName: '苦海无舟',
formlessDescription: '有舟无舟皆可横渡;濒危时恢复气血,并同时获得佛相与魔相的双重防护。',
formlessEffects: [heal(
features.demonOneFurnace ? 0.08 : 0.05,
[{ type: 'hp_below', params: { scope: 'caster', value: 0.3, timing: 'cast' } }],
)],
formlessCompletionEffects: cDone(),
});
return {
'flower-heart': flower,
'blood-tide': blood,
'three-knocks': knocks,
'observe-calamity': observe,
'five-skandhas': skandhas,
'reed-crossing': reed,
};
}
export function compileDemonPassive(
features: WuxiangBuildSettings,
api: WuxiangCompilerApi,
): SectCompiledAbility {
const {
DEMON_CONTROL_GUARD, WUXIANG_DEMON_PATH_ID, WUXIANG_SECT_ID, buff, heal,
passiveDefinition, selfBuff, shield, techniqueTag,
} = api;
const factory = new SectAbilityFactory(WUXIANG_SECT_ID);
const thresholdListener = (
id: string,
threshold: number,
effects: EffectConfig[],
): ListenerConfig => ({
id,
eventType: GameplayTags.EVENT.ABILITY_COST_PAID,
scope: GameplayTags.SCOPE.OWNER_AS_CASTER,
priority: 2,
mapping: { caster: 'owner', target: 'owner' },
budget: { maxTriggers: 1, reset: 'battle' },
conditions: [
{ type: 'ability_has_exact_tag', params: { tag: techniqueTag } },
{ type: 'ability_cost_crossed', params: { value: threshold } },
],
effects,
});
const listeners: ListenerConfig[] = [
{
id: 'sect.wuxiang.demon.lifesteal',
eventType: GameplayTags.EVENT.DAMAGE_TAKEN,
scope: GameplayTags.SCOPE.OWNER_AS_CASTER,
priority: 0,
mapping: { caster: 'owner', target: 'event.target' },
guard: { skipSecondaryDamageSource: true },
conditions: [
{ type: 'damage_source_is', params: { damageSource: DamageSource.DIRECT } },
{ type: 'ability_has_exact_tag', params: { tag: techniqueTag } },
{
type: 'buff_layer_at_least',
params: { scope: 'caster', id: DEMON_CONTROL_GUARD, value: 1 },
},
],
effects: [{
type: 'lifesteal',
params: {
ratio: 0.25,
maxHpRatioPerAction: features.demonLifestealCap,
},
}],
},
...(features.demonThresholdShield
? [thresholdListener('sect.wuxiang.demon.three-shores', 0.35, [shield(0.08)])]
: []),
...(features.demonControlThreshold
? [thresholdListener('sect.wuxiang.demon.body-breaks', 0.3, [
selfBuff(buff('sect.wuxiang.demon.body-breaks-guard', '身坏心明', 1, [{
id: 'sect.wuxiang.demon.body-breaks-guard.immune',
eventType: GameplayTags.EVENT.BUFF_ADD,
scope: GameplayTags.SCOPE.OWNER_AS_TARGET,
priority: 100,
mapping: { caster: 'owner', target: 'owner' },
effects: [{ type: 'buff_immunity', params: { tags: [GameplayTags.BUFF.TYPE.CONTROL] } }],
}], { dispelPolicy: 'protected' })),
])]
: []),
...(features.demonLowHpHealThreshold
? [thresholdListener('sect.wuxiang.demon.blood-empty', 0.25, [heal(0.05)])]
: []),
];
return factory.passive({
definition: passiveDefinition('demon-core'),
pathId: WUXIANG_DEMON_PATH_ID,
listeners,
});
}
@@ -0,0 +1,93 @@
import {
ConfiguredSectNodePlugin,
type SectMeridianNodeDefinition,
} from '../../../../core';
import {
DEMON_BUILD_FACADE,
DemonBuildFacade,
} from '../../shared/buildFacades';
function demonNode(
definition: SectMeridianNodeDefinition,
apply: (facade: DemonBuildFacade) => void,
): ConfiguredSectNodePlugin {
return new ConfiguredSectNodePlugin(definition, (_context, builder) => {
apply(builder.requireExtension<DemonBuildFacade>(DEMON_BUILD_FACADE, '魔心渡厄构筑'));
});
}
export const WUXIANG_DEMON_NODES = [
demonNode(
{ id: 'demon-blood-oil', layerId: '1', name: '血作灯油', description: '所有佛相神通气血成本提高1个百分点;成功施展后获得2%最大气血护盾。' },
(demon) => demon.strengthenBuddhistBody(),
),
demonNode(
{ id: 'demon-three-shores', layerId: '1', name: '三岸留痕', description: '每场战斗首次因自身神通成本降至35%气血以下时,获得8%最大气血护盾。' },
(demon) => demon.addThresholdShield(),
),
demonNode(
{ id: 'demon-bone-tide', layerId: '1', name: '潮伏骨中', description: '血海听潮获得的基础护盾由10%提高至15%最大气血。' },
(demon) => demon.strengthenTideShield(),
),
demonNode(
{ id: 'demon-flower-inward', layerId: '2', name: '花开向内', description: '心隙令下一次宗门直接伤害提高25%。' },
(demon) => demon.strengthenHeartGap(),
),
demonNode(
{ id: 'demon-no-return-tide', layerId: '2', name: '潮不回头', description: '血海听潮生成的下一门宗门直接伤害加成由20%提高至30%;无相仍以30%为准。' },
(demon) => demon.strengthenTideDamage(),
),
demonNode(
{ id: 'demon-third-outside', layerId: '2', name: '门外三声', description: '三叩业门强化第三击的气血线由45%提高至55%。' },
(demon) => demon.raiseThirdHitThreshold(),
),
demonNode(
{ id: 'demon-slow-fire', layerId: '3', name: '劫火缓行', description: '闭目观劫的直接伤害减免由40%提高至50%。' },
(demon) => demon.strengthenObserveGuard(),
),
demonNode(
{ id: 'demon-skandhas-fuel', layerId: '3', name: '五蕴作薪', description: '照见五蕴的佛相净化数量由1提高至2;无相追加后总净化数量为3。' },
(demon) => demon.addCleanse(),
),
demonNode(
{ id: 'demon-short-reed', layerId: '3', name: '苇短水长', description: '一苇横江的佛相直接伤害减免由20%提高至30%;魔相最终提高到40%。' },
(demon) => demon.strengthenReedGuard(),
),
demonNode(
{ id: 'demon-first-thought', layerId: '4', name: '第一念魔', description: '进入魔相或无相时获得第一念:下一门攻击神通追加0.35倍物攻伤害,防御神通获得5%最大气血护盾。' },
(demon) => demon.grantFirstThought(),
),
demonNode(
{ id: 'demon-second-shore', layerId: '4', name: '第二岸苦', description: '魔相期间每成功施展一门宗门神通,恢复2.5%最大气血;无相仅触发一次。' },
(demon) => demon.healAfterDemonSkill(),
),
demonNode(
{ id: 'demon-two-gates', layerId: '4', name: '两门同渡', description: '进入魔相时获得6%最大气血护盾。' },
(demon) => demon.shieldOnDemonEntry(),
),
demonNode(
{ id: 'demon-body-breaks', layerId: '5', name: '身坏心明', description: '每场战斗首次因自身神通成本降至30%气血以下时,获得1回合控制免疫。' },
(demon) => demon.grantLowHpControlImmunity(),
),
demonNode(
{ id: 'demon-blood-empty', layerId: '5', name: '血尽潮生', description: '每场战斗首次因自身神通成本降至25%气血以下时,恢复5%最大气血。' },
(demon) => demon.healAtCriticalHp(),
),
demonNode(
{ id: 'demon-leave-boat', layerId: '5', name: '渡后留舟', description: '魔相结束时获得6%最大气血护盾;无相结束不触发。' },
(demon) => demon.shieldOnDemonExit(),
),
demonNode(
{ id: 'demon-one-furnace', layerId: 'ultimate', name: '佛魔同炉', description: '逐门强化无相追加:收束伤害、恢复、护盾或净化获得明确提升。' },
(demon) => demon.strengthenFormlessLayers(),
),
demonNode(
{ id: 'demon-no-gap', layerId: 'ultimate', name: '一息无间', description: '魔相与无相的单行动吸血上限提高至12%最大气血,但失去公共20%直接伤害减免。' },
(demon) => demon.tradeGuardForLifesteal(),
),
demonNode(
{ id: 'demon-look-back', layerId: 'ultimate', name: '回首彼岸', description: '魔相或无相结束时,若存活且低于20%气血,恢复5%最大气血;每次转相最多一次。' },
(demon) => demon.healAfterCrossing(),
),
];
@@ -0,0 +1,257 @@
import { StackRule } from '@shared/engine/battle-v5/buffs/Buff';
import type { EffectConfig, ListenerConfig } from '@shared/engine/battle-v5/core/configs';
import { EventPriorityLevel } from '@shared/engine/battle-v5/core/events';
import { BuffType, DamageSource } from '@shared/engine/battle-v5/core/types';
import { GameplayTags } from '@shared/engine/shared/tag-domain';
import { SectAbilityFactory, type SectCompiledAbility } from '../../../../core';
import type { WuxiangBuildSettings } from '../../shared/buildFacades';
import type { WuxiangCompilerApi } from '../../shared/compiler';
export function compileMirrorAbilities(
features: WuxiangBuildSettings,
api: WuxiangCompilerApi,
): Record<string, SectCompiledAbility> {
const {
KARMA_DOOR, MIRROR_OBSERVE_COUNTER, MIRROR_REED_COUNTER, MIRROR_REFLOW,
MIRROR_SECOND_OBSERVE,
WUXIANG_MIRROR_PATH_ID, addKarma, addKarmaLayers,
allKarmaEffect, buff, directGuard, gainWar, heal, heartVow, karmaDoors,
layeredAbility, mirrorPresent, physical, selfBuff, shield, targetBuff, tideGuard,
} = api;
const formCompletion: EffectConfig[] = [
...(features.mirrorFormlessKarmaLayers > 0
? addKarmaLayers(features.mirrorFormlessKarmaLayers)
: []),
...(features.mirrorFormlessWarRefund > 0
? [gainWar(features.mirrorFormlessWarRefund)]
: []),
];
const formDone = (effects: EffectConfig[] = []): EffectConfig[] => [...effects, ...formCompletion];
const flower = layeredAbility({
id: 'flower-heart', pathId: WUXIANG_MIRROR_PATH_ID, cost: 0.05, target: 'enemy', features,
effects: [physical(0.6)],
completionEffects: [heartVow(features.mirrorVowReduction)],
demonName: '花落问罪',
demonDescription: '佛相立戒仍然结算;若有业痕,再以花落问罪追加伤害,并封闭目标一门伤害神通。',
demonEffects: mirrorPresent([
physical(0.35),
{ type: 'ability_lock', params: { rounds: 1, tags: [GameplayTags.ABILITY.FUNCTION.DAMAGE], maxCount: 1 } },
], features, 'enemy'),
formlessName: '心花两忘',
formlessDescription: '拈花、问罪与无相同时结算;无论现报是否成功,技能结束后重新留下1层业痕。',
formlessEffects: [physical(0.3), ...allKarmaEffect(features, 'enemy')],
formlessCompletionEffects: formDone([addKarma()]),
});
const blood = layeredAbility({
id: 'blood-tide', pathId: WUXIANG_MIRROR_PATH_ID, cost: 0.08, target: 'self', features,
effects: [shield(0.08), tideGuard(features.mirrorTideReduction)],
demonName: '血海回澜',
demonDescription: '佛相护身仍然结算;若有业痕,回澜恢复气血,并在听潮受击时照还一击。',
demonEffects: mirrorPresent([
heal(0.03),
selfBuff(buff(MIRROR_REFLOW, '回澜', 1, [], { dispelPolicy: 'protected' })),
], features, 'self'),
formlessName: '海月同潮',
formlessDescription: '听潮、回澜与海月同现;额外获得护盾,使此身在潮中仍有立足之处。',
formlessEffects: [shield(0.06), ...allKarmaEffect(features, 'self')],
formlessCompletionEffects: formDone(),
});
const doorLayers = features.mirrorDoorLayers;
const knocks = layeredAbility({
id: 'three-knocks', pathId: WUXIANG_MIRROR_PATH_ID, cost: 0.07, target: 'enemy', features,
effects: [physical(0.28), physical(0.28), physical(0.28)],
completionEffects: karmaDoors(doorLayers),
demonName: '业门倒叩',
demonDescription: '佛相三叩仍然结算;若有业痕,再倒叩旧门,按旧业门层数逐扇引爆。',
demonEffects: mirrorPresent([{
type: 'consume_status_trigger',
params: {
match: { id: KARMA_DOOR }, displayName: '业门', consume: 'all', target: 'target',
effects: [physical(0.25)], scaleEffectsByLayer: true,
},
}], features, 'enemy'),
formlessName: '门内无人',
formlessDescription: '三叩、倒叩皆归无相;旧门尽空之处再落一击,技能结束后仍由佛相留下新的业门。',
formlessEffects: [physical(0.35), ...allKarmaEffect(features, 'enemy')],
formlessCompletionEffects: formDone(),
});
const observeReduction = features.mirrorObserveReduction;
const observe = layeredAbility({
id: 'observe-calamity', pathId: WUXIANG_MIRROR_PATH_ID, cost: 0.1, target: 'self', features,
effects: [directGuard(
'sect.wuxiang.mirror.observe',
'观劫',
observeReduction,
{
counter: 0.45,
counterMarker: MIRROR_OBSERVE_COUNTER,
secondTriggerMarker: MIRROR_SECOND_OBSERVE,
},
)],
demonName: '开眼见劫',
demonDescription: '佛相观劫仍然结算;若有业痕,开眼之时照见来者,每次成功减伤后反击。',
demonEffects: mirrorPresent([
selfBuff(buff(MIRROR_OBSERVE_COUNTER, '开眼见劫', 1, [], {
dispelPolicy: 'protected', stackRule: StackRule.STACK_LAYER, maxLayers: 2,
})),
], features, 'self'),
formlessName: '劫相俱寂',
formlessDescription: '闭目与开眼本无分别;观劫可承受两次直接伤害,两次皆可触发魔相反击。',
formlessEffects: [
selfBuff(buff(MIRROR_SECOND_OBSERVE, '劫相俱寂', 1, [], { dispelPolicy: 'protected' })),
selfBuff(buff(MIRROR_OBSERVE_COUNTER, '开眼见劫', 1, [], {
dispelPolicy: 'protected', stackRule: StackRule.STACK_LAYER, maxLayers: 2,
})),
...allKarmaEffect(features, 'self'),
],
formlessCompletionEffects: formDone(),
});
const fade = targetBuff(buff('sect.wuxiang.mirror.skandhas-fade', '五蕴衰', 1, [{
id: 'sect.wuxiang.mirror.skandhas-fade.trigger',
eventType: GameplayTags.EVENT.DAMAGE_REQUEST,
scope: GameplayTags.SCOPE.OWNER_AS_CASTER,
priority: EventPriorityLevel.DAMAGE_REQUEST + 1,
mapping: { caster: 'owner', target: 'event.target' },
guard: { skipSecondaryDamageSource: true },
budget: { maxTriggers: 1, reset: 'buff_lifetime' },
conditions: [{ type: 'damage_source_is', params: { damageSource: DamageSource.DIRECT } }],
effects: [{ type: 'percent_damage_modifier', params: { mode: 'reduce', value: 0.08 } }],
}], { type: BuffType.DEBUFF, tags: [GameplayTags.BUFF.TYPE.DEBUFF] }));
const skandhas = layeredAbility({
id: 'five-skandhas', pathId: WUXIANG_MIRROR_PATH_ID, cost: 0.06, target: 'enemy', features,
effects: [
physical(0.5),
{
type: 'dispel',
params: {
status: 'positive', maxCount: 1,
effects: addKarmaLayers(features.mirrorSkandhasKarmaLayers),
fallbackEffects: [fade],
},
},
],
demonName: '五蕴还照',
demonDescription: '佛相照破外法仍然结算;若有业痕,再将镜光还照自身,净化一个可移除减益。',
demonEffects: mirrorPresent([{
type: 'dispel', params: { recipient: 'caster', status: 'negative', maxCount: 1 },
}], features, 'enemy'),
formlessName: '五蕴皆空',
formlessDescription: '外法、内执一并照空;追加伤害,并以空相护住色身。',
formlessEffects: [physical(0.4), shield(0.05), ...allKarmaEffect(features, 'enemy')],
formlessCompletionEffects: formDone(),
});
const reed = layeredAbility({
id: 'reed-crossing', pathId: WUXIANG_MIRROR_PATH_ID, cost: 0.08, target: 'self', features,
effects: [directGuard(
'sect.wuxiang.mirror.reed',
'彼岸',
features.mirrorReedReduction,
{ counter: 0.55, counterMarker: MIRROR_REED_COUNTER },
)],
demonName: '一苇倒渡',
demonDescription: '佛相彼岸仍然结算;若有业痕,来力触岸之后顺势倒渡,照还攻击者。',
demonEffects: mirrorPresent([
selfBuff(buff(MIRROR_REED_COUNTER, '一苇倒渡', 1, [], { dispelPolicy: 'protected' })),
], features, 'self'),
formlessName: '此岸非岸',
formlessDescription: '此岸与彼岸皆非定相;获得护盾,再以一苇承住来力与倒渡。',
formlessEffects: [shield(0.1), ...allKarmaEffect(features, 'self')],
formlessCompletionEffects: formDone(),
});
return {
'flower-heart': flower,
'blood-tide': blood,
'three-knocks': knocks,
'observe-calamity': observe,
'five-skandhas': skandhas,
'reed-crossing': reed,
};
}
export function compileMirrorPassive(
features: WuxiangBuildSettings,
api: WuxiangCompilerApi,
): SectCompiledAbility {
const {
WUXIANG_KARMA_BUFF, WUXIANG_MIRROR_PATH_ID, WUXIANG_SECT_ID,
addKarma, modeIs, passiveDefinition,
} = api;
const factory = new SectAbilityFactory(WUXIANG_SECT_ID);
const perLayer = features.mirrorReflectPerLayer;
const listeners: ListenerConfig[] = [
{
id: 'sect.wuxiang.mirror.mark',
eventType: GameplayTags.EVENT.DAMAGE_TAKEN,
scope: GameplayTags.SCOPE.OWNER_AS_TARGET,
priority: 2,
mapping: { caster: 'owner', target: 'owner' },
guard: { skipSecondaryDamageSource: true },
budget: { maxTriggers: 1, reset: 'source_action' },
conditions: [
{ type: 'damage_source_is', params: { damageSource: DamageSource.DIRECT } },
modeIs('none'),
],
effects: [addKarma()],
},
...(features.mirrorGuestExtraKarma ? [{
id: 'sect.wuxiang.mirror.guest',
eventType: GameplayTags.EVENT.DAMAGE_TAKEN,
scope: GameplayTags.SCOPE.OWNER_AS_TARGET,
priority: 1,
mapping: { caster: 'owner' as const, target: 'owner' as const },
guard: { skipSecondaryDamageSource: true },
budget: { maxTriggers: 1, reset: 'round' as const },
conditions: [
{ type: 'damage_source_is' as const, params: { damageSource: DamageSource.DIRECT } },
modeIs('none'),
],
effects: [addKarma()],
}] : []),
{
id: 'sect.wuxiang.mirror.reflect',
eventType: GameplayTags.EVENT.DAMAGE_TAKEN,
scope: GameplayTags.SCOPE.OWNER_AS_TARGET,
priority: 0,
mapping: { caster: 'event.caster', target: 'owner' },
guard: { skipSecondaryDamageSource: true },
conditions: [
{ type: 'damage_source_is', params: { damageSource: DamageSource.DIRECT } },
{ ...modeIs('none'), params: { ...modeIs('none').params, scope: 'target' } },
],
effects: [
{ type: 'reflect', params: { ratio: 0.05, ratioPerLayer: perLayer, layerBuffId: WUXIANG_KARMA_BUFF } },
...(features.mirrorFullReflectBonus > 0 ? [{
type: 'reflect' as const,
conditions: [{
type: 'buff_layer_at_least' as const,
params: { scope: 'target' as const, id: WUXIANG_KARMA_BUFF, value: 3 },
}],
params: { ratio: features.mirrorFullReflectBonus },
}] : []),
],
},
];
if (features.mirrorFullDamageReduction > 0) {
listeners.push({
id: 'sect.wuxiang.mirror.full-reduce',
eventType: GameplayTags.EVENT.DAMAGE_REQUEST,
scope: GameplayTags.SCOPE.OWNER_AS_TARGET,
priority: EventPriorityLevel.DAMAGE_REQUEST + 1,
mapping: { caster: 'owner', target: 'owner' },
guard: { skipSecondaryDamageSource: true },
conditions: [
modeIs('none'),
{ type: 'buff_layer_at_least', params: { scope: 'target', id: WUXIANG_KARMA_BUFF, value: 3 } },
],
effects: [{
type: 'percent_damage_modifier',
params: { mode: 'reduce', value: features.mirrorFullDamageReduction },
}],
});
}
return factory.passive({
definition: passiveDefinition('mirror-core'),
pathId: WUXIANG_MIRROR_PATH_ID,
listeners,
});
}
@@ -0,0 +1,94 @@
import {
ConfiguredSectNodePlugin,
type SectMeridianNodeDefinition,
} from '../../../../core';
import {
MIRROR_BUILD_FACADE,
MirrorBuildFacade,
} from '../../shared/buildFacades';
function mirrorNode(
definition: SectMeridianNodeDefinition,
apply: (facade: MirrorBuildFacade) => void,
): ConfiguredSectNodePlugin {
return new ConfiguredSectNodePlugin(definition, (_context, builder) => {
apply(builder.requireExtension<MirrorBuildFacade>(MIRROR_BUILD_FACADE, '明镜照业构筑'));
});
}
export const WUXIANG_MIRROR_NODES = [
mirrorNode(
{ id: 'mirror-vow-body', layerId: '1', name: '戒由身起', description: '所有佛相神通气血成本提高1个百分点;成功施展后获得2%最大气血护盾。' },
(mirror) => mirror.strengthenBuddhistBody(),
),
mirrorNode(
{ id: 'mirror-guest-in-mirror', layerId: '1', name: '镜中留客', description: '每轮首次受到敌方直接伤害时,额外留下1层业痕。' },
(mirror) => mirror.addGuestKarma(),
),
mirrorNode(
{ id: 'mirror-fruit-in-time', layerId: '1', name: '果不逾时', description: '每层业痕提供的佛相反伤比例由3%提高至4%。' },
(mirror) => mirror.strengthenKarmaReflection(),
),
mirrorNode(
{ id: 'mirror-loud-flower', layerId: '2', name: '花落有声', description: '叩心戒的伤害衰减由10%提高至18%。' },
(mirror) => mirror.strengthenHeartVow(),
),
mirrorNode(
{ id: 'mirror-welcome-tide', layerId: '2', name: '潮来不拒', description: '血海听潮的直接伤害减免由15%提高至25%。' },
(mirror) => mirror.strengthenTideGuard(),
),
mirrorNode(
{ id: 'mirror-fourth-knock', layerId: '2', name: '门叩第四声', description: '三叩业门额外留下第四层业门;倒叩时允许追加第四段明确伤害。' },
(mirror) => mirror.addFourthKarmaDoor(),
),
mirrorNode(
{ id: 'mirror-see-guest', layerId: '3', name: '闭目见客', description: '闭目观劫首次直接伤害减免由35%提高至50%。' },
(mirror) => mirror.strengthenObserveGuard(),
),
mirrorNode(
{ id: 'mirror-skandhas-mark', layerId: '3', name: '蕴去痕留', description: '照见五蕴成功驱散增益时获得2层业痕。' },
(mirror) => mirror.gainTwoKarmaOnDispel(),
),
mirrorNode(
{ id: 'mirror-carry-karma', layerId: '3', name: '一苇载业', description: '一苇横江的直接伤害减免由40%提高至50%。' },
(mirror) => mirror.strengthenReedGuard(),
),
mirrorNode(
{ id: 'mirror-form-beyond', layerId: '4', name: '相外有相', description: '魔相止观的直接伤害减免由20%提高至30%。' },
(mirror) => mirror.strengthenDemonGuard(),
),
mirrorNode(
{ id: 'mirror-back-demon', layerId: '4', name: '镜背生魔', description: '每次进入魔相时,第一门魔相神通的魔相追加按已消费1层业痕处理,但不实际减少业痕;无相不受影响。' },
(mirror) => mirror.grantFreeFirstPresent(),
),
mirrorNode(
{ id: 'mirror-formless-two', layerId: '4', name: '一念两照', description: '无相神通成功结算后额外获得2层业痕;《一念无间》气血成本增加2个百分点。' },
(mirror) => mirror.strengthenFormlessKarma(),
),
mirrorNode(
{ id: 'mirror-full-light', layerId: '5', name: '业满成光', description: '业痕满层时,佛相即时反伤额外提高5%。' },
(mirror) => mirror.addFullKarmaReflection(),
),
mirrorNode(
{ id: 'mirror-fast-fruit', layerId: '5', name: '现报无迟', description: '现报成功后,攻击神通追加0.20倍物攻伤害;自身防御神通额外获得4%最大气血护盾。' },
(mirror) => mirror.strengthenPresent(),
),
mirrorNode(
{ id: 'mirror-return-source', layerId: '5', name: '照还来处', description: '每次实际消耗1层业痕触发现报时,恢复2%最大气血;免费现报不触发。' },
(mirror) => mirror.healOnPaidPresent(),
),
mirrorNode(
{ id: 'mirror-not-platform', layerId: 'ultimate', name: '明镜非台', description: '佛相且业痕满层时,受到的直接伤害降低10%。' },
(mirror) => mirror.reduceDamageAtFullKarma(),
),
mirrorNode(
{ id: 'mirror-all-karma', layerId: 'ultimate', name: '万业同门', description: '无相现报实际消费业痕后再尝试消费1层;成功时攻击神通追加0.60倍物攻伤害,防御神通获得8%最大气血护盾。' },
(mirror) => mirror.addSecondFormlessPresent(),
),
mirrorNode(
{ id: 'mirror-return-thought', layerId: 'ultimate', name: '来去一念', description: '无相神通成功结算后返还2点战意。' },
(mirror) => mirror.refundWarAfterFormless(),
),
];
@@ -0,0 +1,146 @@
export interface WuxiangBuildSettings {
buddhistCostBonus: number;
buddhistShieldRatio: number;
mirrorGuestExtraKarma: boolean;
mirrorReflectPerLayer: number;
mirrorVowReduction: number;
mirrorTideReduction: number;
mirrorDoorLayers: number;
mirrorObserveReduction: number;
mirrorSkandhasKarmaLayers: number;
mirrorReedReduction: number;
mirrorDemonReduction: number;
mirrorFreePresent: boolean;
mirrorFormlessKarmaLayers: number;
mirrorFormlessCostBonus: number;
mirrorFullReflectBonus: number;
mirrorPresentAttackBonus: number;
mirrorPresentShieldBonus: number;
mirrorPresentHealRatio: number;
mirrorFullDamageReduction: number;
mirrorSecondPresent: boolean;
mirrorFormlessWarRefund: number;
demonThresholdShield: boolean;
demonTideShieldRatio: number;
demonHeartGapBonus: number;
demonTideDamageBonus: number;
demonThirdHitThreshold: number;
demonObserveReduction: number;
demonCleanseCount: number;
demonReedReduction: number;
demonFirstThought: boolean;
demonSecondShore: boolean;
demonEntryShieldRatio: number;
demonControlThreshold: boolean;
demonLowHpHealThreshold: boolean;
demonExitShieldRatio: number;
demonOneFurnace: boolean;
demonLifestealCap: number;
demonHasPublicReduction: boolean;
demonLookBack: boolean;
}
export const MIRROR_BUILD_FACADE = Symbol('wuxiang-mirror-build');
export const DEMON_BUILD_FACADE = Symbol('wuxiang-demon-build');
export function createWuxiangBuildSettings(): WuxiangBuildSettings {
return {
buddhistCostBonus: 0,
buddhistShieldRatio: 0,
mirrorGuestExtraKarma: false,
mirrorReflectPerLayer: 0.03,
mirrorVowReduction: 0.10,
mirrorTideReduction: 0.15,
mirrorDoorLayers: 3,
mirrorObserveReduction: 0.35,
mirrorSkandhasKarmaLayers: 1,
mirrorReedReduction: 0.40,
mirrorDemonReduction: 0.20,
mirrorFreePresent: false,
mirrorFormlessKarmaLayers: 0,
mirrorFormlessCostBonus: 0,
mirrorFullReflectBonus: 0,
mirrorPresentAttackBonus: 0,
mirrorPresentShieldBonus: 0,
mirrorPresentHealRatio: 0,
mirrorFullDamageReduction: 0,
mirrorSecondPresent: false,
mirrorFormlessWarRefund: 0,
demonThresholdShield: false,
demonTideShieldRatio: 0.10,
demonHeartGapBonus: 0.15,
demonTideDamageBonus: 0.20,
demonThirdHitThreshold: 0.45,
demonObserveReduction: 0.40,
demonCleanseCount: 1,
demonReedReduction: 0.20,
demonFirstThought: false,
demonSecondShore: false,
demonEntryShieldRatio: 0,
demonControlThreshold: false,
demonLowHpHealThreshold: false,
demonExitShieldRatio: 0,
demonOneFurnace: false,
demonLifestealCap: 0.08,
demonHasPublicReduction: true,
demonLookBack: false,
};
}
abstract class WuxiangBuildFacade {
constructor(readonly settings: WuxiangBuildSettings) {}
strengthenBuddhistBody(): void {
this.settings.buddhistCostBonus = 0.01;
this.settings.buddhistShieldRatio = 0.02;
}
}
export class MirrorBuildFacade extends WuxiangBuildFacade {
addGuestKarma(): void { this.settings.mirrorGuestExtraKarma = true; }
strengthenKarmaReflection(): void { this.settings.mirrorReflectPerLayer = 0.04; }
strengthenHeartVow(): void { this.settings.mirrorVowReduction = 0.18; }
strengthenTideGuard(): void { this.settings.mirrorTideReduction = 0.25; }
addFourthKarmaDoor(): void { this.settings.mirrorDoorLayers = 4; }
strengthenObserveGuard(): void { this.settings.mirrorObserveReduction = 0.50; }
gainTwoKarmaOnDispel(): void { this.settings.mirrorSkandhasKarmaLayers = 2; }
strengthenReedGuard(): void { this.settings.mirrorReedReduction = 0.50; }
strengthenDemonGuard(): void { this.settings.mirrorDemonReduction = 0.30; }
grantFreeFirstPresent(): void { this.settings.mirrorFreePresent = true; }
strengthenFormlessKarma(): void {
this.settings.mirrorFormlessKarmaLayers = 2;
this.settings.mirrorFormlessCostBonus = 0.02;
}
addFullKarmaReflection(): void { this.settings.mirrorFullReflectBonus = 0.05; }
strengthenPresent(): void {
this.settings.mirrorPresentAttackBonus = 0.20;
this.settings.mirrorPresentShieldBonus = 0.04;
}
healOnPaidPresent(): void { this.settings.mirrorPresentHealRatio = 0.02; }
reduceDamageAtFullKarma(): void { this.settings.mirrorFullDamageReduction = 0.10; }
addSecondFormlessPresent(): void { this.settings.mirrorSecondPresent = true; }
refundWarAfterFormless(): void { this.settings.mirrorFormlessWarRefund = 2; }
}
export class DemonBuildFacade extends WuxiangBuildFacade {
addThresholdShield(): void { this.settings.demonThresholdShield = true; }
strengthenTideShield(): void { this.settings.demonTideShieldRatio = 0.15; }
strengthenHeartGap(): void { this.settings.demonHeartGapBonus = 0.25; }
strengthenTideDamage(): void { this.settings.demonTideDamageBonus = 0.30; }
raiseThirdHitThreshold(): void { this.settings.demonThirdHitThreshold = 0.55; }
strengthenObserveGuard(): void { this.settings.demonObserveReduction = 0.50; }
addCleanse(): void { this.settings.demonCleanseCount = 2; }
strengthenReedGuard(): void { this.settings.demonReedReduction = 0.30; }
grantFirstThought(): void { this.settings.demonFirstThought = true; }
healAfterDemonSkill(): void { this.settings.demonSecondShore = true; }
shieldOnDemonEntry(): void { this.settings.demonEntryShieldRatio = 0.06; }
grantLowHpControlImmunity(): void { this.settings.demonControlThreshold = true; }
healAtCriticalHp(): void { this.settings.demonLowHpHealThreshold = true; }
shieldOnDemonExit(): void { this.settings.demonExitShieldRatio = 0.06; }
strengthenFormlessLayers(): void { this.settings.demonOneFurnace = true; }
tradeGuardForLifesteal(): void {
this.settings.demonLifestealCap = 0.12;
this.settings.demonHasPublicReduction = false;
}
healAfterCrossing(): void { this.settings.demonLookBack = true; }
}
@@ -0,0 +1,824 @@
import { StackRule } from '@shared/engine/battle-v5/buffs/Buff';
import type {
AbilityCostConfig,
AbilityEffectLayerConfig,
AbilityEffectPlanConfig,
BuffConfig,
ConditionConfig,
EffectConfig,
ListenerConfig,
} from '@shared/engine/battle-v5/core/configs';
import { EventPriorityLevel } from '@shared/engine/battle-v5/core/events';
import {
AttributeType,
BuffType,
DamageSource,
DamageType,
} from '@shared/engine/battle-v5/core/types';
import { GameplayTags } from '@shared/engine/shared/tag-domain';
import {
SectAbilityFactory,
type SectBuildBuilder,
type SectCompiledAbility,
type SectPathId,
type SectProjectionContext,
} from '../../../core';
import { WUXIANG_BASE_DEFINITION } from '../definition';
import {
WUXIANG_DEMON_PATH_ID,
WUXIANG_FORM_MODE,
WUXIANG_KARMA_BUFF,
WUXIANG_MIRROR_PATH_ID,
WUXIANG_SECT_ID,
WUXIANG_TECHNIQUE_IDS,
WUXIANG_WAR_INTENT,
type WuxiangTechniqueId,
} from '../ids';
import {
createWuxiangBuildSettings,
type WuxiangBuildSettings,
} from './buildFacades';
import {
compileMirrorAbilities,
compileMirrorPassive,
} from '../paths/mirror/compiler';
import {
compileDemonAbilities,
compileDemonPassive,
} from '../paths/demon/compiler';
const techniqueTag = GameplayTags.ABILITY.SECT.mechanic(WUXIANG_SECT_ID, 'technique');
const MIRROR_GUARD = 'sect.wuxiang.mirror.stillness';
const MIRROR_FREE_PRESENT = 'sect.wuxiang.mirror.free-present';
const MIRROR_REFLOW = 'sect.wuxiang.mirror.reflow';
const MIRROR_SECOND_OBSERVE = 'sect.wuxiang.mirror.observe-second';
const MIRROR_OBSERVE_COUNTER = 'sect.wuxiang.mirror.observe-counter';
const MIRROR_REED_COUNTER = 'sect.wuxiang.mirror.reed-counter';
const DEMON_GUARD = 'sect.wuxiang.demon.crossing-guard';
const DEMON_CONTROL_GUARD = 'sect.wuxiang.demon.control-guard';
const DEMON_FIRST_THOUGHT = 'sect.wuxiang.demon.first-thought';
const KARMA_DOOR = 'sect.wuxiang.mirror.karma-door';
const HEART_GAP = 'sect.wuxiang.demon.heart-gap';
const modeIs = (mode: string, remainingUses?: number): ConditionConfig => ({
type: 'ability_mode_is',
params: { scope: 'caster', key: WUXIANG_FORM_MODE, mode, remainingUses },
});
const hpCost = (ratio: number, features: WuxiangBuildSettings): AbilityCostConfig[] => {
const buddhistBonus = features.buddhistCostBonus;
if (buddhistBonus === 0) {
return [{ resource: 'hp', mode: 'current_hp_ratio', ratio, minimum: 1, retain: 1 }];
}
return [
{
resource: 'hp', mode: 'current_hp_ratio', ratio: ratio + buddhistBonus,
minimum: 1, retain: 1, conditions: [modeIs('none')],
},
{
resource: 'hp', mode: 'current_hp_ratio', ratio,
minimum: 1, retain: 1, conditions: [modeIs('demon')],
},
{
resource: 'hp', mode: 'current_hp_ratio', ratio,
minimum: 1, retain: 1, conditions: [modeIs('formless')],
},
];
};
const physical = (
coefficient: number,
conditions?: ConditionConfig[],
options: { forceCritical?: boolean; source?: DamageSource; dynamicMissingHpCap?: number } = {},
): EffectConfig => ({
type: 'damage',
conditions,
params: {
value: { attribute: AttributeType.ATK, coefficient },
damageType: DamageType.PHYSICAL,
damageSource: options.source ?? DamageSource.DIRECT,
forceCritical: options.forceCritical,
dynamicScalars: options.dynamicMissingHpCap
? [{
source: 'target_missing_hp_ratio',
attribute: AttributeType.ATK,
coefficientCap: options.dynamicMissingHpCap,
timing: 'cast',
}]
: undefined,
},
});
const shield = (ratio: number): EffectConfig => ({
type: 'shield',
params: { value: { targetMaxHpRatio: ratio }, target: 'caster' },
});
const heal = (ratio: number, conditions?: ConditionConfig[]): EffectConfig => ({
type: 'heal',
conditions,
params: { value: { targetMaxHpRatio: ratio }, recipient: 'caster', target: 'hp' },
});
const gainWar = (amount = 1, conditions?: ConditionConfig[]): EffectConfig => ({
type: 'combat_resource_modify',
conditions,
params: {
resourceId: WUXIANG_WAR_INTENT,
operation: 'add',
amount,
target: 'caster',
reason: 'gain',
},
});
const spendWar = (amount: number | 'all'): EffectConfig => ({
type: 'combat_resource_modify',
params: {
resourceId: WUXIANG_WAR_INTENT,
operation: amount === 'all' ? 'consume_all' : 'subtract',
amount: amount === 'all' ? undefined : amount,
target: 'caster',
reason: 'spend',
},
});
function buff(
id: string,
name: string,
duration: number,
listeners: ListenerConfig[] = [],
options: Partial<BuffConfig> = {},
): BuffConfig {
return {
id,
name,
type: BuffType.BUFF,
duration,
stackRule: StackRule.OVERRIDE,
tags: [GameplayTags.BUFF.TYPE.BUFF, GameplayTags.BUFF.SECT.namespace(WUXIANG_SECT_ID, id)],
listeners,
...options,
};
}
const selfBuff = (config: BuffConfig, conditions?: ConditionConfig[]): EffectConfig => ({
type: 'apply_buff',
conditions,
params: { target: 'caster', buffConfig: config },
});
const targetBuff = (config: BuffConfig, conditions?: ConditionConfig[]): EffectConfig => ({
type: 'apply_buff',
conditions,
params: { target: 'target', buffConfig: config },
});
const clearBuff = (id: string, target: 'caster' | 'target' = 'caster'): EffectConfig => ({
type: 'buff_layer_modify',
params: { match: { id }, operation: 'clear', target },
});
const addKarma = (): EffectConfig => selfBuff(buff(
WUXIANG_KARMA_BUFF,
'业痕',
-1,
[],
{
description: '佛相承受来力留下的因;魔相神通可逐层现报。',
stackRule: StackRule.STACK_LAYER,
maxLayers: 3,
dispelPolicy: 'protected',
},
));
const addKarmaLayers = (layers: number): EffectConfig[] =>
Array.from({ length: layers }, addKarma);
function directGuard(
id: string,
name: string,
reduction: number,
options: {
counter?: number;
counterMarker?: string;
secondTriggerMarker?: string;
} = {},
): EffectConfig {
const counterEffects = options.counter
? [physical(options.counter, undefined, { source: DamageSource.COUNTER })]
: [];
const reductionListener = (
suffix: string,
priority: number,
conditions: ConditionConfig[],
): ListenerConfig => ({
id: `${id}.${suffix}`,
eventType: GameplayTags.EVENT.DAMAGE_REQUEST,
scope: GameplayTags.SCOPE.OWNER_AS_TARGET,
priority,
mapping: { caster: 'owner', target: 'owner' },
guard: { skipSecondaryDamageSource: true },
budget: { maxTriggers: 1, reset: 'buff_lifetime' },
conditions: [
{ type: 'damage_source_is', params: { damageSource: DamageSource.DIRECT } },
...conditions,
],
effects: [{ type: 'percent_damage_modifier', params: { mode: 'reduce', value: reduction } }],
});
const listeners: ListenerConfig[] = [];
if (options.secondTriggerMarker) {
listeners.push(reductionListener('second', EventPriorityLevel.DAMAGE_REQUEST + 2, [
{
type: 'buff_layer_at_least',
params: { scope: 'target', id: options.secondTriggerMarker, value: 1 },
},
{ type: 'runtime_counter_compare', params: { scope: 'target', key: `${id}.first-seen`, value: 1, op: 'gte' } },
]));
}
listeners.push({
...reductionListener('first', EventPriorityLevel.DAMAGE_REQUEST + 1, []),
effects: [
{ type: 'percent_damage_modifier', params: { mode: 'reduce', value: reduction } },
{ type: 'runtime_counter_modify', params: { key: `${id}.first-seen`, operation: 'set', amount: 1, target: 'target' } },
],
});
if (counterEffects.length > 0) {
listeners.push({
id: `${id}.counter`,
eventType: GameplayTags.EVENT.DAMAGE_TAKEN,
scope: GameplayTags.SCOPE.OWNER_AS_TARGET,
priority: 0,
mapping: { caster: 'owner', target: 'event.caster' },
guard: { skipSecondaryDamageSource: true },
budget: { maxTriggers: options.secondTriggerMarker ? 2 : 1, reset: 'buff_lifetime' },
conditions: [
{ type: 'damage_source_is', params: { damageSource: DamageSource.DIRECT } },
...(options.counterMarker
? [{
type: 'buff_layer_at_least' as const,
params: { scope: 'caster' as const, id: options.counterMarker, value: 1 },
}]
: []),
],
effects: [
...counterEffects,
...(options.counterMarker ? [{
type: 'buff_layer_modify' as const,
params: {
match: { id: options.counterMarker },
operation: 'subtract' as const,
layers: 1,
target: 'caster' as const,
},
}] : []),
],
});
}
return selfBuff(buff(id, name, 1, listeners, { dispelPolicy: 'protected' }));
}
function persistentDirectReduction(id: string, name: string, reduction: number): EffectConfig {
return selfBuff(buff(id, name, -1, [{
id: `${id}.reduce`,
eventType: GameplayTags.EVENT.DAMAGE_REQUEST,
scope: GameplayTags.SCOPE.OWNER_AS_TARGET,
priority: EventPriorityLevel.DAMAGE_REQUEST + 1,
mapping: { caster: 'owner', target: 'owner' },
guard: { skipSecondaryDamageSource: true },
conditions: [{ type: 'damage_source_is', params: { damageSource: DamageSource.DIRECT } }],
effects: [{ type: 'percent_damage_modifier', params: { mode: 'reduce', value: reduction } }],
}], { dispelPolicy: 'protected' }));
}
function definition(id: WuxiangTechniqueId | 'turn-form') {
const found = WUXIANG_BASE_DEFINITION.abilities.find(
(ability) => ability.id === id && ability.kind !== 'passive',
);
if (!found || found.kind === 'passive') throw new Error(`无相神通未定义: ${id}`);
return found;
}
interface LayeredAbilitySpec {
id: WuxiangTechniqueId;
pathId: SectPathId;
cost: number;
target: 'enemy' | 'self';
effects: EffectConfig[];
completionEffects?: EffectConfig[];
demonName: string;
demonDescription: string;
demonEffects: EffectConfig[];
demonCompletionEffects?: EffectConfig[];
formlessName: string;
formlessDescription: string;
formlessEffects: EffectConfig[];
formlessCompletionEffects?: EffectConfig[];
features: WuxiangBuildSettings;
}
function layeredAbility(spec: LayeredAbilitySpec): SectCompiledAbility {
const factory = new SectAbilityFactory(WUXIANG_SECT_ID);
const layers: AbilityEffectLayerConfig[] = [
{
id: 'demon',
effects: spec.demonEffects,
completionEffects: spec.demonCompletionEffects,
},
{
id: 'formless',
effects: spec.formlessEffects,
completionEffects: spec.formlessCompletionEffects,
},
];
const plans: AbilityEffectPlanConfig[] = [
{
id: 'formless',
name: spec.formlessName,
description: spec.formlessDescription,
priority: 300,
conditions: [modeIs('formless')],
layerIds: ['demon', 'formless'],
consumeModeKey: WUXIANG_FORM_MODE,
},
{
id: 'demon',
name: spec.demonName,
description: spec.demonDescription,
priority: 200,
conditions: [modeIs('demon')],
layerIds: ['demon'],
consumeModeKey: WUXIANG_FORM_MODE,
},
];
const buddhistCompletion: EffectConfig[] = [
...(spec.completionEffects ?? []),
gainWar(1, [modeIs('none')]),
...(spec.features.buddhistShieldRatio > 0
? [shield(spec.features.buddhistShieldRatio) as EffectConfig]
.map((effect) => ({ ...effect, conditions: [modeIs('none')] }))
: []),
];
return factory.active({
definition: definition(spec.id),
pathId: spec.pathId,
costs: hpCost(spec.cost, spec.features),
targetPolicy: { team: spec.target, scope: 'single' },
effects: spec.effects,
completionEffects: buddhistCompletion,
effectLayers: layers,
effectPlans: plans,
extraTags: [techniqueTag],
});
}
function mirrorPresent(
effects: EffectConfig[],
features: WuxiangBuildSettings,
target: 'enemy' | 'self',
): EffectConfig[] {
const reward: EffectConfig[] = [
...effects,
...(features.mirrorPresentAttackBonus > 0
? [target === 'enemy'
? physical(features.mirrorPresentAttackBonus)
: shield(features.mirrorPresentShieldBonus)]
: []),
...(features.mirrorPresentHealRatio > 0 ? [heal(features.mirrorPresentHealRatio)] : []),
];
const actual: EffectConfig = {
type: 'consume_status_trigger',
params: {
match: { id: WUXIANG_KARMA_BUFF },
displayName: '业痕',
consume: 1,
target: 'caster',
effects: reward,
},
};
if (!features.mirrorFreePresent) return [actual];
const freeReward = [
...effects,
...(features.mirrorPresentAttackBonus > 0
? [target === 'enemy'
? physical(features.mirrorPresentAttackBonus)
: shield(features.mirrorPresentShieldBonus)]
: []),
];
return [{
type: 'consume_status_trigger',
params: {
match: { id: MIRROR_FREE_PRESENT },
displayName: '免费现报',
consume: 1,
target: 'caster',
effects: freeReward,
fallbackEffects: [actual],
},
}];
}
function allKarmaEffect(
features: WuxiangBuildSettings,
target: 'enemy' | 'self',
): EffectConfig[] {
if (!features.mirrorSecondPresent) return [];
return [{
type: 'consume_status_trigger',
params: {
match: { id: WUXIANG_KARMA_BUFF },
displayName: '业痕',
consume: 1,
target: 'caster',
effects: [target === 'enemy' ? physical(0.6) : shield(0.08)],
},
}];
}
function heartVow(reduction: number): EffectConfig {
return targetBuff(buff('sect.wuxiang.mirror.heart-vow', '叩心戒', 1, [{
id: 'sect.wuxiang.mirror.heart-vow.trigger',
eventType: GameplayTags.EVENT.DAMAGE_REQUEST,
scope: GameplayTags.SCOPE.OWNER_AS_CASTER,
priority: EventPriorityLevel.DAMAGE_REQUEST + 1,
mapping: { caster: 'event.target', target: 'owner' },
guard: { skipSecondaryDamageSource: true },
budget: { maxTriggers: 1, reset: 'buff_lifetime' },
conditions: [{ type: 'damage_source_is', params: { damageSource: DamageSource.DIRECT } }],
effects: [
{ type: 'percent_damage_modifier', params: { mode: 'reduce', value: reduction } },
addKarma(),
],
}], { type: BuffType.DEBUFF, tags: [GameplayTags.BUFF.TYPE.DEBUFF] }));
}
function tideGuard(reduction: number): EffectConfig {
return selfBuff(buff('sect.wuxiang.mirror.tide', '听潮', 1, [
{
id: 'sect.wuxiang.mirror.tide.reduce',
eventType: GameplayTags.EVENT.DAMAGE_REQUEST,
scope: GameplayTags.SCOPE.OWNER_AS_TARGET,
priority: EventPriorityLevel.DAMAGE_REQUEST + 1,
mapping: { caster: 'owner', target: 'owner' },
guard: { skipSecondaryDamageSource: true },
conditions: [{ type: 'damage_source_is', params: { damageSource: DamageSource.DIRECT } }],
effects: [{ type: 'percent_damage_modifier', params: { mode: 'reduce', value: reduction } }],
},
{
id: 'sect.wuxiang.mirror.tide.counter',
eventType: GameplayTags.EVENT.DAMAGE_TAKEN,
scope: GameplayTags.SCOPE.OWNER_AS_TARGET,
priority: 0,
mapping: { caster: 'owner', target: 'event.caster' },
guard: { skipSecondaryDamageSource: true },
budget: { maxTriggers: 1, reset: 'buff_lifetime' },
conditions: [
{ type: 'damage_source_is', params: { damageSource: DamageSource.DIRECT } },
{ type: 'buff_layer_at_least', params: { scope: 'caster', id: MIRROR_REFLOW, value: 1 } },
],
effects: [physical(0.35, undefined, { source: DamageSource.COUNTER }), clearBuff(MIRROR_REFLOW)],
},
], { dispelPolicy: 'protected' }));
}
function karmaDoors(layers: number): EffectConfig[] {
const door = buff(KARMA_DOOR, '业门', 4, [{
id: 'sect.wuxiang.mirror.karma-door.trigger',
eventType: GameplayTags.EVENT.DAMAGE_TAKEN,
scope: GameplayTags.SCOPE.OWNER_AS_CASTER,
priority: 0,
mapping: { caster: 'event.target', target: 'owner' },
guard: { skipSecondaryDamageSource: true },
conditions: [{ type: 'damage_source_is', params: { damageSource: DamageSource.DIRECT } }],
effects: [
physical(0.12, undefined, { source: DamageSource.COUNTER }),
{
type: 'buff_layer_modify',
params: { match: { id: KARMA_DOOR }, operation: 'subtract', layers: 1, target: 'target' },
},
],
}], {
type: BuffType.DEBUFF,
stackRule: StackRule.STACK_LAYER,
maxLayers: layers,
tags: [GameplayTags.BUFF.TYPE.DEBUFF],
});
return Array.from({ length: layers }, () => targetBuff(door));
}
function outgoingBoost(
id: string,
name: string,
bonus: number,
options: { healOnHit?: number } = {},
): EffectConfig {
return selfBuff(buff(id, name, 2, [
{
id: `${id}.boost`,
eventType: GameplayTags.EVENT.DAMAGE_REQUEST,
scope: GameplayTags.SCOPE.OWNER_AS_CASTER,
priority: EventPriorityLevel.DAMAGE_REQUEST + 1,
mapping: { caster: 'owner', target: 'event.target' },
guard: { skipSecondaryDamageSource: true },
conditions: [
{ type: 'damage_source_is', params: { damageSource: DamageSource.DIRECT } },
{ type: 'ability_has_exact_tag', params: { tag: techniqueTag } },
],
effects: [{ type: 'percent_damage_modifier', params: { mode: 'increase', value: bonus } }],
},
...(options.healOnHit ? [{
id: `${id}.heal`,
eventType: GameplayTags.EVENT.DAMAGE_TAKEN,
scope: GameplayTags.SCOPE.OWNER_AS_CASTER,
priority: 0,
mapping: { caster: 'owner' as const, target: 'event.target' as const },
guard: { skipSecondaryDamageSource: true },
budget: { maxTriggers: 1, reset: 'buff_lifetime' as const },
conditions: [
{ type: 'damage_source_is' as const, params: { damageSource: DamageSource.DIRECT } },
{ type: 'ability_has_exact_tag' as const, params: { tag: techniqueTag } },
],
effects: [heal(options.healOnHit)],
}] : []),
{
id: `${id}.consume`,
eventType: GameplayTags.EVENT.SKILL_CAST,
scope: GameplayTags.SCOPE.OWNER_AS_CASTER,
priority: 0,
mapping: { caster: 'owner', target: 'event.target' },
budget: { maxTriggers: 1, reset: 'buff_lifetime' },
conditions: [{ type: 'ability_has_exact_tag', params: { tag: techniqueTag } }],
effects: [clearBuff(id)],
},
], { dispelPolicy: 'protected' }));
}
function heartGap(bonus: number): EffectConfig {
return targetBuff(buff(HEART_GAP, '心隙', 2, [
{
id: 'sect.wuxiang.demon.heart-gap.boost',
eventType: GameplayTags.EVENT.DAMAGE_REQUEST,
scope: GameplayTags.SCOPE.OWNER_AS_TARGET,
priority: EventPriorityLevel.DAMAGE_REQUEST + 1,
mapping: { caster: 'event.caster', target: 'owner' },
guard: { skipSecondaryDamageSource: true },
conditions: [
{ type: 'damage_source_is', params: { damageSource: DamageSource.DIRECT } },
{ type: 'ability_has_exact_tag', params: { tag: techniqueTag } },
],
effects: [{ type: 'percent_damage_modifier', params: { mode: 'increase', value: bonus } }],
},
{
id: 'sect.wuxiang.demon.heart-gap.consume',
eventType: GameplayTags.EVENT.SKILL_CAST,
scope: GameplayTags.SCOPE.OWNER_AS_TARGET,
priority: 0,
mapping: { caster: 'event.caster', target: 'owner' },
budget: { maxTriggers: 1, reset: 'source_action' },
conditions: [{ type: 'ability_has_exact_tag', params: { tag: techniqueTag } }],
effects: [{
type: 'buff_layer_modify',
params: { match: { id: HEART_GAP }, operation: 'subtract', layers: 1, target: 'target' },
}],
},
], {
type: BuffType.DEBUFF,
stackRule: StackRule.STACK_LAYER,
maxLayers: 2,
tags: [GameplayTags.BUFF.TYPE.DEBUFF],
}));
}
function firstThoughtBonus(target: 'enemy' | 'self'): EffectConfig {
return {
type: 'consume_status_trigger',
params: {
match: { id: DEMON_FIRST_THOUGHT },
displayName: '第一念',
consume: 1,
target: 'caster',
effects: [target === 'enemy' ? physical(0.35) : shield(0.05)],
},
};
}
function demonCompletion(
features: WuxiangBuildSettings,
mode: 'demon' | 'formless',
): EffectConfig[] {
const finalDemon = modeIs('demon', 1);
return [
...(mode === 'demon' && features.demonSecondShore ? [heal(0.025)] : []),
...(mode === 'demon' && features.demonExitShieldRatio > 0
? [shield(features.demonExitShieldRatio) as EffectConfig]
.map((effect) => ({ ...effect, conditions: [finalDemon] }))
: []),
...(features.demonLookBack
? [heal(0.05, [
mode === 'demon' ? finalDemon : modeIs('formless', 1),
{ type: 'hp_below', params: { scope: 'caster', value: 0.2 } },
])]
: []),
];
}
function turnForm(pathId: SectPathId, features: WuxiangBuildSettings): SectCompiledAbility {
const factory = new SectAbilityFactory(WUXIANG_SECT_ID);
const isMirror = pathId === WUXIANG_MIRROR_PATH_ID;
const guardIds = isMirror
? [
MIRROR_GUARD,
...(features.mirrorFreePresent ? [MIRROR_FREE_PRESENT] : []),
]
: [
DEMON_GUARD,
DEMON_CONTROL_GUARD,
...(features.demonFirstThought ? [DEMON_FIRST_THOUGHT] : []),
];
const demonEffects: EffectConfig[] = [
spendWar(3),
{
type: 'ability_mode',
params: {
key: WUXIANG_FORM_MODE,
operation: 'set',
mode: 'demon',
remainingUses: 2,
displayName: isMirror ? '魔相·止观' : '魔相·渡厄',
cleanupBuffIds: guardIds,
},
},
...(isMirror
? [persistentDirectReduction(
MIRROR_GUARD,
'止观',
features.mirrorDemonReduction,
)]
: [
...(features.demonHasPublicReduction
? [persistentDirectReduction(DEMON_GUARD, '渡厄', 0.2)]
: []),
selfBuff(buff(DEMON_CONTROL_GUARD, '渡厄·免控', -1, [{
id: 'sect.wuxiang.demon.control-guard.immune',
eventType: GameplayTags.EVENT.BUFF_ADD,
scope: GameplayTags.SCOPE.OWNER_AS_TARGET,
priority: 100,
mapping: { caster: 'owner', target: 'owner' },
effects: [{ type: 'buff_immunity', params: { tags: [GameplayTags.BUFF.TYPE.CONTROL] } }],
}], { dispelPolicy: 'protected' })),
]),
...(isMirror && features.mirrorFreePresent
? [selfBuff(buff(MIRROR_FREE_PRESENT, '镜背生魔', -1, [], {
dispelPolicy: 'protected', stackRule: StackRule.STACK_LAYER, maxLayers: 1,
}), [{
type: 'combat_resource_below',
params: { scope: 'caster', resourceId: WUXIANG_WAR_INTENT, value: 3 },
}])]
: []),
...(!isMirror && features.demonFirstThought
? [selfBuff(buff(DEMON_FIRST_THOUGHT, '第一念', -1, [], {
dispelPolicy: 'protected', stackRule: StackRule.STACK_LAYER, maxLayers: 1,
}))]
: []),
...(!isMirror && features.demonEntryShieldRatio > 0
? [{
...shield(features.demonEntryShieldRatio),
conditions: [{
type: 'combat_resource_below',
params: { scope: 'caster', resourceId: WUXIANG_WAR_INTENT, value: 3 },
}],
} as EffectConfig]
: []),
];
const formlessEffects: EffectConfig[] = [
spendWar('all'),
{
type: 'ability_mode',
params: {
key: WUXIANG_FORM_MODE,
operation: 'set',
mode: 'formless',
remainingUses: 1,
displayName: '一念无间',
cleanupBuffIds: guardIds,
},
},
];
const formlessCost = 0.08 + (isMirror ? features.mirrorFormlessCostBonus : 0);
return factory.active({
definition: definition('turn-form'),
pathId,
targetPolicy: { team: 'self', scope: 'single' },
costs: [
{
resource: 'hp', mode: 'current_hp_ratio', ratio: 0.04, minimum: 1, retain: 1,
conditions: [{
type: 'combat_resource_below',
params: { scope: 'caster', resourceId: WUXIANG_WAR_INTENT, value: 6 },
}],
},
{
resource: 'hp', mode: 'current_hp_ratio', ratio: formlessCost, minimum: 1, retain: 1,
conditions: [{
type: 'combat_resource_at_least',
params: { scope: 'caster', resourceId: WUXIANG_WAR_INTENT, value: 6 },
}],
},
],
effects: [],
effectLayers: [
{ id: 'demon', effects: demonEffects },
{ id: 'formless', effects: formlessEffects },
],
effectPlans: [
{
id: 'formless', name: '一念无间', priority: 300,
description: '消耗全部战意与气血,使下一门宗门神通同时显化佛相、魔相与无相三层效果。',
conditions: [{
type: 'combat_resource_at_least',
params: { scope: 'caster', resourceId: WUXIANG_WAR_INTENT, value: 6 },
}],
layerIds: ['demon', 'formless'],
},
{
id: 'demon', name: '魔相入身', priority: 200,
description: '消耗3点战意与少量气血,使之后两门宗门神通在佛相主体上追加魔相效果。',
conditions: [{
type: 'combat_resource_at_least',
params: { scope: 'caster', resourceId: WUXIANG_WAR_INTENT, value: 3 },
}],
layerIds: ['demon'],
},
],
castConditions: [{
type: 'combat_resource_at_least',
params: { scope: 'caster', resourceId: WUXIANG_WAR_INTENT, value: 3 },
}],
selectionProfile: { intents: ['buff'] },
});
}
function passiveDefinition(id: 'mirror-core' | 'demon-core') {
return WUXIANG_BASE_DEFINITION.abilities.find(
(entry) => entry.id === id && entry.kind === 'passive',
)! as Extract<(typeof WUXIANG_BASE_DEFINITION.abilities)[number], { kind: 'passive' }>;
}
const compilerApi = {
DEMON_CONTROL_GUARD,
HEART_GAP,
KARMA_DOOR,
MIRROR_OBSERVE_COUNTER,
MIRROR_REED_COUNTER,
MIRROR_REFLOW,
MIRROR_SECOND_OBSERVE,
WUXIANG_DEMON_PATH_ID,
WUXIANG_KARMA_BUFF,
WUXIANG_MIRROR_PATH_ID,
WUXIANG_SECT_ID,
addKarma,
addKarmaLayers,
allKarmaEffect,
buff,
clearBuff,
demonCompletion,
directGuard,
firstThoughtBonus,
gainWar,
heal,
heartGap,
heartVow,
karmaDoors,
layeredAbility,
mirrorPresent,
modeIs,
outgoingBoost,
passiveDefinition,
physical,
selfBuff,
shield,
targetBuff,
techniqueTag,
tideGuard,
} as const;
export type WuxiangCompilerApi = typeof compilerApi;
export function compileWuxiangBase(
_context: SectProjectionContext,
builder: SectBuildBuilder,
): void {
const empty = createWuxiangBuildSettings();
const abilities = compileMirrorAbilities(empty, compilerApi);
for (const id of WUXIANG_TECHNIQUE_IDS) builder.setAbility(id, abilities[id]);
builder.setAbility('turn-form', turnForm(WUXIANG_MIRROR_PATH_ID, empty));
builder.setResource({ ...WUXIANG_BASE_DEFINITION.combatResource, initial: 0 });
}
export function compileWuxiangPath(
builder: SectBuildBuilder,
pathId: SectPathId,
features: WuxiangBuildSettings,
): void {
const isMirror = pathId === WUXIANG_MIRROR_PATH_ID;
const abilities = isMirror
? compileMirrorAbilities(features, compilerApi)
: compileDemonAbilities(features, compilerApi);
for (const id of WUXIANG_TECHNIQUE_IDS) builder.setAbility(id, abilities[id]);
builder.setAbility('turn-form', turnForm(pathId, features));
builder.setAbility(
isMirror ? 'mirror-core' : 'demon-core',
isMirror
? compileMirrorPassive(features, compilerApi)
: compileDemonPassive(features, compilerApi),
);
}
@@ -41,9 +41,10 @@ export class WuxiangMirrorSelectionStrategy extends WuxiangSelectionStrategy {
const karma = context.caster.buffs.getAllBuffs()
.find((buff) => buff.id === WUXIANG_KARMA_BUFF)?.getLayer() ?? 0;
if (mode?.mode === 'demon') {
return this.result(context, mode.phase === 1
? ['observe-calamity', 'blood-tide', 'five-skandhas', 'three-knocks']
: ['three-knocks', 'five-skandhas', 'flower-heart', 'reed-crossing'], 720);
const priorities = this.tacticId === 'guard'
? ['observe-calamity', 'reed-crossing', 'blood-tide', 'three-knocks']
: ['three-knocks', 'flower-heart', 'five-skandhas', 'observe-calamity'];
return this.result(context, priorities, 720);
}
if (mode?.mode === 'formless') {
return this.result(context, ['three-knocks', 'flower-heart', 'observe-calamity', 'blood-tide'], 760);
@@ -67,12 +68,14 @@ export class WuxiangDemonSelectionStrategy extends WuxiangSelectionStrategy {
const war = context.caster.combatResources.getCurrent(WUXIANG_WAR_INTENT);
const hp = context.caster.getHpPercent();
if (mode?.mode === 'demon') {
return this.result(context, mode.phase === 1
? ['blood-tide', 'observe-calamity', 'five-skandhas', 'flower-heart']
: ['three-knocks', 'reed-crossing', 'flower-heart', 'five-skandhas'], 720);
return this.result(context, hp < 0.3
? ['reed-crossing', 'observe-calamity', 'blood-tide', 'five-skandhas']
: ['three-knocks', 'flower-heart', 'blood-tide', 'observe-calamity'], 720);
}
if (mode?.mode === 'formless') {
return this.result(context, ['blood-tide', 'three-knocks', 'reed-crossing', 'flower-heart'], 760);
return this.result(context, hp < 0.3
? ['reed-crossing', 'three-knocks', 'flower-heart', 'observe-calamity']
: ['three-knocks', 'flower-heart', 'blood-tide', 'observe-calamity'], 760);
}
const shouldTurn = this.tacticId === 'trial-fire'
? war >= 3 && hp < 0.6
@@ -8,185 +8,274 @@ import { AbilityFactory } from '@shared/engine/battle-v5/factories/AbilityFactor
import { BuffFactory } from '@shared/engine/battle-v5/factories/BuffFactory';
import { Unit } from '@shared/engine/battle-v5/units/Unit';
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import { projectSectCombat } from '../..';
import { projectSectCombat, resolveSectAbility } from '../..';
import type { CultivatorSectState } from '../../../core';
import {
WUXIANG_FORM_MODE,
WUXIANG_KARMA_BUFF,
WUXIANG_TECHNIQUE_IDS,
WUXIANG_WAR_INTENT,
} from '..';
type PathId = 'mirror-karma' | 'demon-crossing';
type Form = 'buddha' | 'demon-entry' | 'demon-finish' | 'formless';
type Form = 'buddha' | 'demon' | 'formless';
const names: Record<PathId, Record<string, [string, string, string, string]>> = {
const names: Record<PathId, Record<string, [string, string, string]>> = {
'mirror-karma': {
'flower-heart': ['拈花叩心', '花落问罪', '花落问罪', '心花两忘'],
'blood-tide': ['血海听潮', '血海回澜', '血海回澜', '海月同潮'],
'three-knocks': ['三叩业门', '业门倒叩', '业门倒叩', '门内无人'],
'observe-calamity': ['闭目观劫', '开眼见劫', '开眼见劫', '劫相俱寂'],
'five-skandhas': ['照见五蕴', '五蕴还照', '五蕴还照', '五蕴皆空'],
'reed-crossing': ['一苇横江', '一苇倒渡', '一苇倒渡', '此岸非岸'],
'flower-heart': ['拈花叩心', '花落问罪', '心花两忘'],
'blood-tide': ['血海听潮', '血海回澜', '海月同潮'],
'three-knocks': ['三叩业门', '业门倒叩', '门内无人'],
'observe-calamity': ['闭目观劫', '开眼见劫', '劫相俱寂'],
'five-skandhas': ['照见五蕴', '五蕴还照', '五蕴皆空'],
'reed-crossing': ['一苇横江', '一苇倒渡', '此岸非岸'],
},
'demon-crossing': {
'flower-heart': ['拈花叩心', '摘心问魔', '摘心问魔', '心魔两忘'],
'blood-tide': ['血海听潮', '血海倒悬', '血海倒悬', '血海无涯'],
'three-knocks': ['三叩业门', '三叩魔关', '三叩魔关', '业门无生'],
'observe-calamity': ['闭目观劫', '开眼见魔', '开眼见魔', '劫火自明'],
'five-skandhas': ['照见五蕴', '焚尽五蕴', '焚尽五蕴', '蕴空身在'],
'reed-crossing': ['一苇横江', '一苇渡厄', '一苇渡厄', '苦海无舟'],
'flower-heart': ['拈花叩心', '摘心问魔', '心魔两忘'],
'blood-tide': ['血海听潮', '血海倒悬', '血海无涯'],
'three-knocks': ['三叩业门', '三叩魔关', '业门无生'],
'observe-calamity': ['闭目观劫', '开眼见魔', '劫火自明'],
'five-skandhas': ['照见五蕴', '焚尽五蕴', '蕴空身在'],
'reed-crossing': ['一苇横江', '一苇渡厄', '苦海无舟'],
},
};
const costs: Record<PathId, Record<string, [number, number, number, number]>> = {
const costs: Record<PathId, Record<string, number>> = {
'mirror-karma': {
'flower-heart': [0.05, 0.05, 0.05, 0.08], 'blood-tide': [0.08, 0.06, 0.06, 0.1],
'three-knocks': [0.07, 0.07, 0.07, 0.11], 'observe-calamity': [0.1, 0.06, 0.06, 0.12],
'five-skandhas': [0.06, 0.06, 0.06, 0.09], 'reed-crossing': [0.08, 0.07, 0.07, 0.11],
'flower-heart': 0.05,
'blood-tide': 0.08,
'three-knocks': 0.07,
'observe-calamity': 0.10,
'five-skandhas': 0.06,
'reed-crossing': 0.08,
},
'demon-crossing': {
'flower-heart': [0.06, 0.05, 0.05, 0.09], 'blood-tide': [0.14, 0.07, 0.07, 0.16],
'three-knocks': [0.09, 0.08, 0.08, 0.13], 'observe-calamity': [0.11, 0.06, 0.06, 0.12],
'five-skandhas': [0.07, 0.06, 0.06, 0.1], 'reed-crossing': [0.1, 0.06, 0.06, 0.12],
'flower-heart': 0.06,
'blood-tide': 0.14,
'three-knocks': 0.09,
'observe-calamity': 0.11,
'five-skandhas': 0.07,
'reed-crossing': 0.10,
},
};
function state(pathId: PathId, abilityId: string): CultivatorSectState {
const fillers = WUXIANG_TECHNIQUE_IDS
.filter((id) => id !== abilityId && id !== 'flower-heart')
.slice(0, abilityId === 'flower-heart' ? 3 : 2);
const targets: Record<PathId, Record<string, 'enemy' | 'self'>> = {
'mirror-karma': {
'flower-heart': 'enemy',
'blood-tide': 'self',
'three-knocks': 'enemy',
'observe-calamity': 'self',
'five-skandhas': 'enemy',
'reed-crossing': 'self',
},
'demon-crossing': {
'flower-heart': 'enemy',
'blood-tide': 'self',
'three-knocks': 'enemy',
'observe-calamity': 'self',
'five-skandhas': 'self',
'reed-crossing': 'self',
},
};
const cooldowns: Record<string, number> = {
'flower-heart': 0,
'blood-tide': 3,
'three-knocks': 2,
'observe-calamity': 4,
'five-skandhas': 3,
'reed-crossing': 5,
};
const damageSegments: Record<PathId, Record<string, Record<Form, number[]>>> = {
'mirror-karma': {
'flower-heart': { buddha: [0.6], demon: [0.6, 0.35], formless: [0.6, 0.35, 0.3] },
'blood-tide': { buddha: [], demon: [], formless: [] },
'three-knocks': {
buddha: [0.28, 0.28, 0.28],
demon: [0.28, 0.28, 0.28, 0.25],
formless: [0.28, 0.28, 0.28, 0.25, 0.35],
},
'observe-calamity': { buddha: [], demon: [], formless: [] },
'five-skandhas': { buddha: [0.5], demon: [0.5], formless: [0.5, 0.4] },
'reed-crossing': { buddha: [], demon: [], formless: [] },
},
'demon-crossing': {
'flower-heart': { buddha: [0.6], demon: [0.6, 0.35], formless: [0.6, 0.35, 0.2] },
'blood-tide': { buddha: [], demon: [], formless: [] },
'three-knocks': {
buddha: [0.25, 0.25, 0.25],
demon: [0.25, 0.25, 0.25, 0.45],
formless: [0.25, 0.25, 0.25, 0.45],
},
'observe-calamity': { buddha: [], demon: [], formless: [] },
'five-skandhas': { buddha: [], demon: [], formless: [] },
'reed-crossing': { buddha: [], demon: [], formless: [] },
},
};
function state(pathId: PathId): CultivatorSectState {
return {
membershipId: 'matrix', sectId: 'wuxiang', status: 'active', contribution: 0,
configVersion: 1, activePathId: pathId,
membershipId: 'runtime',
sectId: 'wuxiang',
status: 'active',
contribution: 0,
configVersion: 2,
activePathId: pathId,
methods: {
'wuxiang-canon': 5, 'blood-lotus': 3, 'white-bone': 3,
'wrathful-ming': 3, 'six-senses': 3, 'reed-crossing-method': 3,
'wuxiang-canon': 5,
'blood-lotus': 3,
'white-bone': 3,
'wrathful-ming': 3,
'six-senses': 3,
'reed-crossing-method': 3,
},
paths: [{
pathId, unlockedLayerIds: ['1', '2', '3', '4', '5', 'ultimate'],
tacticId: pathId === 'mirror-karma' ? 'guard' : 'trial-fire', activeMeridianSlot: 1,
pathId,
unlockedLayerIds: ['1', '2', '3', '4', '5', 'ultimate'],
tacticId: pathId === 'mirror-karma' ? 'guard' : 'trial-fire',
activeMeridianSlot: 1,
meridianLoadouts: [
{ slot: 1, nodeIds: [], version: 1 },
{ slot: 2, nodeIds: [], version: 1 },
{ slot: 3, nodeIds: [], version: 1 },
],
}],
abilityLoadout: ['turn-form', ...(abilityId === 'flower-heart' ? [] : [abilityId]), ...fillers],
abilityLoadout: ['turn-form', 'blood-tide', 'three-knocks', 'observe-calamity'],
};
}
function unit(id: string): Unit {
return new Unit(id, id, {
[AttributeType.VITALITY]: 100, [AttributeType.SPIRIT]: 100,
[AttributeType.WISDOM]: 100, [AttributeType.SPEED]: 100,
[AttributeType.VITALITY]: 100,
[AttributeType.SPIRIT]: 100,
[AttributeType.WISDOM]: 100,
[AttributeType.SPEED]: 100,
[AttributeType.WILLPOWER]: 100,
});
}
const forms: Form[] = ['buddha', 'demon-entry', 'demon-finish', 'formless'];
const forms: Form[] = ['buddha', 'demon', 'formless'];
const cases = (['mirror-karma', 'demon-crossing'] as PathId[]).flatMap((pathId) =>
WUXIANG_TECHNIQUE_IDS.flatMap((abilityId) =>
forms.map((form) => ({ pathId, abilityId, form }))),
);
forms.map((form) => ({ pathId, abilityId, form }))));
function expectedTeam(pathId: PathId, abilityId: string, form: Form): 'enemy' | 'self' {
if (pathId === 'mirror-karma') {
if (abilityId === 'reed-crossing') return 'self';
if (abilityId === 'blood-tide' && (form === 'buddha' || form === 'formless')) return 'self';
if (abilityId === 'observe-calamity' && form === 'buddha') return 'self';
return 'enemy';
}
if (
form === 'buddha' &&
['blood-tide', 'observe-calamity', 'five-skandhas', 'reed-crossing'].includes(abilityId)
) return 'self';
return 'enemy';
}
function expectedIntent(
pathId: PathId,
abilityId: string,
form: Form,
): 'damage' | 'buff' | 'defensive' {
if (expectedTeam(pathId, abilityId, form) === 'enemy') return 'damage';
if (
pathId === 'demon-crossing' &&
form === 'buddha' &&
(abilityId === 'blood-tide' || abilityId === 'five-skandhas')
) return 'buff';
return 'defensive';
}
describe('无相禅宗48格实际结算矩阵', () => {
describe('无相禅宗36格实际结算矩阵', () => {
beforeEach(() => EventBus.instance.reset());
afterEach(() => EventBus.instance.reset());
it.each(cases)('$pathId/$abilityId/$form冻结并执行对应形态', ({ pathId, abilityId, form }) => {
const projection = projectSectCombat({ sect: state(pathId, abilityId), realm: '化神' })!;
it.each(cases)('$pathId/$abilityId/$form 冻结并执行对应 A/B/C 计划', ({
pathId,
abilityId,
form,
}) => {
const projection = projectSectCombat({ sect: state(pathId), realm: '化神' })!;
const owner = unit('owner');
const enemy = unit('enemy');
if (
pathId === 'demon-crossing' &&
abilityId === 'flower-heart' &&
form === 'formless'
) {
enemy.setHp(Math.floor(enemy.getMaxHp() * 0.5));
}
for (const resource of projection.resources) owner.combatResources.define(resource);
const defaultAttack = AbilityFactory.create(projection.defaultAttack!) as ActiveSkill;
owner.abilities.setDefaultAttack(defaultAttack);
for (const config of projection.abilities) owner.abilities.addAbility(AbilityFactory.create(config));
const skill = abilityId === 'flower-heart'
? defaultAttack
: owner.abilities.getAbility(`sect.wuxiang.${abilityId}`) as ActiveSkill;
const config = resolveSectAbility({ sect: state(pathId), realm: '化神', abilityId }).config;
const skill = AbilityFactory.create(config) as ActiveSkill;
skill.setOwner(owner);
skill.setActive(true);
const formIndex = forms.indexOf(form);
if (form === 'demon-entry' || form === 'demon-finish') {
if (form !== 'buddha') {
setAbilityMode(owner, {
key: WUXIANG_FORM_MODE, mode: 'demon',
phase: form === 'demon-entry' ? 1 : 2,
remainingUses: form === 'demon-entry' ? 2 : 1,
displayName: '魔相',
key: WUXIANG_FORM_MODE,
mode: form,
remainingUses: form === 'demon' ? 2 : 1,
displayName: form === 'demon' ? '魔相' : '无相',
});
if (pathId === 'mirror-karma') {
const karma = BuffFactory.create({
id: WUXIANG_KARMA_BUFF, name: '业痕', type: BuffType.BUFF,
duration: -1, stackRule: StackRule.STACK_LAYER, maxLayers: 3,
});
owner.buffs.addBuff(karma, owner);
owner.buffs.addBuff(BuffFactory.create({
id: WUXIANG_KARMA_BUFF,
name: '业痕',
type: BuffType.BUFF,
duration: -1,
stackRule: StackRule.STACK_LAYER,
maxLayers: 3,
}), owner);
if (abilityId === 'three-knocks') {
enemy.buffs.addBuff(BuffFactory.create({
id: 'sect.wuxiang.mirror.karma-door',
name: '旧业门',
type: BuffType.DEBUFF,
duration: 4,
stackRule: StackRule.STACK_LAYER,
maxLayers: 3,
}), owner);
}
}
} else if (form === 'formless') {
setAbilityMode(owner, {
key: WUXIANG_FORM_MODE, mode: 'formless', phase: 1,
remainingUses: 1, displayName: '无相待发',
});
}
const target = skill.targetPolicy.team === 'self' ? owner : enemy;
const expectedName = names[pathId][abilityId][formIndex];
const expectedRatio = costs[pathId][abilityId][formIndex];
const target = targets[pathId][abilityId] === 'self' ? owner : enemy;
const formIndex = forms.indexOf(form);
const hpBefore = owner.getCurrentHp();
let paid: AbilityCostPaidEvent | undefined;
let frozenVariant: string | undefined;
const requests: DamageRequestEvent[] = [];
let frozenPlanId: string | undefined;
const damageRequests: DamageRequestEvent[] = [];
EventBus.instance.subscribe<AbilityCostPaidEvent>('AbilityCostPaidEvent', (event) => {
if (event.ability === skill) {
paid = event;
frozenVariant = event.ability.runtimeVariantId;
}
if (event.ability !== skill) return;
paid = event;
frozenPlanId = event.ability.runtimePlanId;
});
EventBus.instance.subscribe<DamageRequestEvent>('DamageRequestEvent', (event) => {
if (event.ability === skill) requests.push(event);
if (event.ability === skill) damageRequests.push(event);
});
expect(skill.name).toBe(expectedName);
expect(skill.targetPolicy.team).toBe(expectedTeam(pathId, abilityId, form));
expect(skill.selectionProfile?.intents).toEqual([
expectedIntent(pathId, abilityId, form),
]);
expect(skill.id).toBe(`sect.wuxiang.${abilityId}`);
expect(skill.name).toBe(names[pathId][abilityId][formIndex]);
expect(skill.targetPolicy.team).toBe(targets[pathId][abilityId]);
expect(skill.maxCooldown).toBe(cooldowns[abilityId]);
expect(config.effects?.length).toBeGreaterThan(0);
expect(config.effectLayers?.find((layer) => layer.id === 'demon')?.effects?.length)
.toBeGreaterThan(0);
expect(config.effectLayers?.find((layer) => layer.id === 'formless')?.effects?.length)
.toBeGreaterThan(0);
skill.prepareCast({ caster: owner, target });
skill.execute({ caster: owner, target });
expect(paid?.hpPaid).toBe(Math.ceil(hpBefore * expectedRatio));
expect(frozenVariant).toBeTruthy();
expect(requests.every((event) => event.target === enemy)).toBe(true);
if (form === 'demon-entry') {
expect(readAbilityMode(owner, WUXIANG_FORM_MODE)).toMatchObject({ phase: 2, remainingUses: 1 });
} else if (form === 'demon-finish' || form === 'formless') {
expect(paid?.hpPaid).toBe(Math.ceil(hpBefore * costs[pathId][abilityId]));
expect(frozenPlanId).toBe(form === 'buddha' ? undefined : form);
expect(damageRequests.every((event) => event.target === enemy)).toBe(true);
expect(damageRequests.map((event) =>
event.damageComponents?.find((component) =>
component.segmentMultiplier !== undefined)?.segmentMultiplier ?? 0))
.toEqual(damageSegments[pathId][abilityId][form]);
expect(owner.combatResources.getCurrent(WUXIANG_WAR_INTENT))
.toBe(form === 'buddha' ? 1 : 0);
if (form === 'demon') {
expect(readAbilityMode(owner, WUXIANG_FORM_MODE)).toMatchObject({
mode: 'demon',
remainingUses: 1,
});
} else {
expect(readAbilityMode(owner, WUXIANG_FORM_MODE)).toBeUndefined();
}
});
it('未命中不执行完成效果,也不消费魔相次数', () => {
const projection = projectSectCombat({ sect: state('mirror-karma'), realm: '化神' })!;
const owner = unit('owner');
const enemy = unit('enemy');
for (const resource of projection.resources) owner.combatResources.define(resource);
const skill = AbilityFactory.create(projection.defaultAttack!) as ActiveSkill;
skill.setOwner(owner);
setAbilityMode(owner, {
key: WUXIANG_FORM_MODE,
mode: 'demon',
remainingUses: 2,
displayName: '魔相',
});
skill.prepareCast({ caster: owner, target: enemy });
skill.execute({ caster: owner, target: enemy, shouldApplyEffects: false });
expect(readAbilityMode(owner, WUXIANG_FORM_MODE)).toMatchObject({ remainingUses: 2 });
expect(owner.combatResources.getCurrent(WUXIANG_WAR_INTENT)).toBe(0);
});
});
@@ -1,34 +1,29 @@
import { describe, expect, it } from 'vitest';
import { projectSectCombat, PRODUCTION_SECT_IDS, resolveSectAbility } from '../..';
import type { CultivatorSectState } from '../../../core';
import { SectStateValidator, type CultivatorSectState } from '../../../core';
import { WUXIANG_MODULE, WUXIANG_TECHNIQUE_IDS } from '..';
function containsSubset(value: unknown, subset: unknown): boolean {
if (subset === null || typeof subset !== 'object') return Object.is(value, subset);
if (Array.isArray(subset)) {
return Array.isArray(value) && subset.every((expected) =>
value.some((actual) => containsSubset(actual, expected)));
}
if (Array.isArray(value)) return value.some((child) => containsSubset(child, subset));
if (value === null || typeof value !== 'object') return false;
const record = value as Record<string, unknown>;
const expected = subset as Record<string, unknown>;
const direct = Object.entries(expected).every(([key, child]) =>
key in record && containsSubset(record[key], child));
if (direct) return true;
return Object.values(record).some((child) => containsSubset(child, subset));
}
type PathId = 'mirror-karma' | 'demon-crossing';
function state(pathId: 'mirror-karma' | 'demon-crossing', nodes: string[] = []): CultivatorSectState {
function state(pathId: PathId, nodes: string[] = []): CultivatorSectState {
return {
membershipId: 'w1', sectId: 'wuxiang', status: 'active', contribution: 0,
configVersion: 1, activePathId: pathId,
membershipId: 'w1',
sectId: 'wuxiang',
status: 'active',
contribution: 0,
configVersion: 2,
activePathId: pathId,
methods: {
'wuxiang-canon': 5, 'blood-lotus': 3, 'white-bone': 3,
'wrathful-ming': 3, 'six-senses': 3, 'reed-crossing-method': 3,
'wuxiang-canon': 5,
'blood-lotus': 3,
'white-bone': 3,
'wrathful-ming': 3,
'six-senses': 3,
'reed-crossing-method': 3,
},
paths: [{
pathId, unlockedLayerIds: ['1', '2', '3', '4', '5', 'ultimate'],
pathId,
unlockedLayerIds: ['1', '2', '3', '4', '5', 'ultimate'],
tacticId: pathId === 'mirror-karma' ? 'guard' : 'trial-fire',
activeMeridianSlot: 1,
meridianLoadouts: [
@@ -41,9 +36,34 @@ function state(pathId: 'mirror-karma' | 'demon-crossing', nodes: string[] = []):
};
}
const abilityTable: Record<PathId, Record<string, {
names: [string, string, string];
target: 'enemy' | 'self';
cost: number;
cooldown: number;
}>> = {
'mirror-karma': {
'flower-heart': { names: ['拈花叩心', '花落问罪', '心花两忘'], target: 'enemy', cost: 0.05, cooldown: 0 },
'blood-tide': { names: ['血海听潮', '血海回澜', '海月同潮'], target: 'self', cost: 0.08, cooldown: 3 },
'three-knocks': { names: ['三叩业门', '业门倒叩', '门内无人'], target: 'enemy', cost: 0.07, cooldown: 2 },
'observe-calamity': { names: ['闭目观劫', '开眼见劫', '劫相俱寂'], target: 'self', cost: 0.10, cooldown: 4 },
'five-skandhas': { names: ['照见五蕴', '五蕴还照', '五蕴皆空'], target: 'enemy', cost: 0.06, cooldown: 3 },
'reed-crossing': { names: ['一苇横江', '一苇倒渡', '此岸非岸'], target: 'self', cost: 0.08, cooldown: 5 },
},
'demon-crossing': {
'flower-heart': { names: ['拈花叩心', '摘心问魔', '心魔两忘'], target: 'enemy', cost: 0.06, cooldown: 0 },
'blood-tide': { names: ['血海听潮', '血海倒悬', '血海无涯'], target: 'self', cost: 0.14, cooldown: 3 },
'three-knocks': { names: ['三叩业门', '三叩魔关', '业门无生'], target: 'enemy', cost: 0.09, cooldown: 2 },
'observe-calamity': { names: ['闭目观劫', '开眼见魔', '劫火自明'], target: 'self', cost: 0.11, cooldown: 4 },
'five-skandhas': { names: ['照见五蕴', '焚尽五蕴', '蕴空身在'], target: 'self', cost: 0.07, cooldown: 3 },
'reed-crossing': { names: ['一苇横江', '一苇渡厄', '苦海无舟'], target: 'self', cost: 0.10, cooldown: 5 },
},
};
describe('无相禅宗战斗投影', () => {
it('作为独立宗门进入生产目录,并保持双道途各六层三选一', () => {
expect(PRODUCTION_SECT_IDS).toContain('wuxiang');
expect(WUXIANG_MODULE.definition.configVersion).toBe(2);
expect(WUXIANG_MODULE.definition.paths).toHaveLength(2);
for (const path of WUXIANG_MODULE.definition.paths) {
expect(path.layers).toHaveLength(6);
@@ -56,196 +76,197 @@ describe('无相禅宗战斗投影', () => {
});
it.each(['mirror-karma', 'demon-crossing'] as const)(
'%s的六门神通均编译出佛、魔一式、魔二式与无相式',
'%s 只接受 configVersion=2 的当前状态',
(pathId) => {
const sect = state(pathId);
expect(sect.configVersion).toBe(2);
expect(() => new SectStateValidator().validate(WUXIANG_MODULE, sect)).not.toThrow();
expect(WUXIANG_MODULE.definition.configVersion).toBe(2);
},
);
it.each(['mirror-karma', 'demon-crossing'] as const)(
'%s 的六门神通只使用 A、B、C 效果层,不再生成完整变体',
(pathId) => {
const sect = state(pathId);
const projection = projectSectCombat({ sect, realm: '化神' })!;
expect(projection.resources[0]).toMatchObject({ name: '战意', initial: 0, max: 6 });
expect(projection.selectionStrategy).toBeDefined();
for (const abilityId of WUXIANG_TECHNIQUE_IDS) {
const detail = resolveSectAbility({ sect, realm: '化神', abilityId });
const variants = detail.config.variants ?? [];
expect(variants).toHaveLength(4);
expect(variants.filter((variant) => variant.id.includes('formless'))).toHaveLength(1);
expect(variants.filter((variant) =>
variant.id.startsWith('mirror.demon.') ||
variant.id.startsWith('demon.entry.') ||
variant.id.startsWith('demon.finish.'),
)).toHaveLength(2);
for (const variant of variants) {
expect(variant.costs?.[0]).toMatchObject({ resource: 'hp', mode: 'current_hp_ratio', retain: 1 });
}
const config = resolveSectAbility({ sect, realm: '化神', abilityId }).config;
expect('variants' in config).toBe(false);
expect(config.effectLayers?.map((layer) => layer.id)).toEqual(['demon', 'formless']);
expect(config.effectPlans?.map((plan) => ({
id: plan.id,
layers: plan.layerIds,
consumeModeKey: plan.consumeModeKey,
}))).toEqual([
{ id: 'formless', layers: ['demon', 'formless'], consumeModeKey: 'sect.wuxiang.form' },
{ id: 'demon', layers: ['demon'], consumeModeKey: 'sect.wuxiang.form' },
]);
}
},
);
it.each([
['mirror-karma', 'flower-heart', ['拈花叩心', '花落问罪', '花落问罪', '心花两忘'], [0.05, 0.05, 0.05, 0.08]],
['mirror-karma', 'blood-tide', ['血海听潮', '血海回澜', '血海回澜', '海月同潮'], [0.08, 0.06, 0.06, 0.10]],
['mirror-karma', 'three-knocks', ['三叩业门', '业门倒叩', '业门倒叩', '门内无人'], [0.07, 0.07, 0.07, 0.11]],
['mirror-karma', 'observe-calamity', ['闭目观劫', '开眼见劫', '开眼见劫', '劫相俱寂'], [0.10, 0.06, 0.06, 0.12]],
['mirror-karma', 'five-skandhas', ['照见五蕴', '五蕴还照', '五蕴还照', '五蕴皆空'], [0.06, 0.06, 0.06, 0.09]],
['mirror-karma', 'reed-crossing', ['一苇横江', '一苇倒渡', '一苇倒渡', '此岸非岸'], [0.08, 0.07, 0.07, 0.11]],
['demon-crossing', 'flower-heart', ['拈花叩心', '摘心问魔', '摘心问魔', '心魔两忘'], [0.06, 0.05, 0.05, 0.09]],
['demon-crossing', 'blood-tide', ['血海听潮', '血海倒悬', '血海倒悬', '血海无涯'], [0.14, 0.07, 0.07, 0.16]],
['demon-crossing', 'three-knocks', ['三叩业门', '三叩魔关', '三叩魔关', '业门无生'], [0.09, 0.08, 0.08, 0.13]],
['demon-crossing', 'observe-calamity', ['闭目观劫', '开眼见魔', '开眼见魔', '劫火自明'], [0.11, 0.06, 0.06, 0.12]],
['demon-crossing', 'five-skandhas', ['照见五蕴', '焚尽五蕴', '焚尽五蕴', '蕴空身在'], [0.07, 0.06, 0.06, 0.10]],
['demon-crossing', 'reed-crossing', ['一苇横江', '一苇渡厄', '一苇渡厄', '苦海无舟'], [0.10, 0.06, 0.06, 0.12]],
] as const)(
'%s/%s的四形名称与气血成本与设计表一致',
(pathId, abilityId, names, costs) => {
const variants = resolveSectAbility({ sect: state(pathId), realm: '化神', abilityId }).config.variants ?? [];
const ordered = pathId === 'mirror-karma'
? [
variants.find((variant) => variant.id === `mirror.buddha.${abilityId}`),
variants.find((variant) => variant.id === `mirror.demon.1.${abilityId}`),
variants.find((variant) => variant.id === `mirror.demon.2.${abilityId}`),
variants.find((variant) => variant.id === `mirror.formless.${abilityId}`),
]
: [
variants.find((variant) => variant.id === `demon.buddha.${abilityId}`),
variants.find((variant) => variant.id === `demon.entry.${abilityId}`),
variants.find((variant) => variant.id === `demon.finish.${abilityId}`),
variants.find((variant) => variant.id === `demon.formless.${abilityId}`),
];
expect(ordered.map((variant) => variant?.name)).toEqual([...names]);
expect(ordered.every((variant) => Boolean(variant?.description))).toBe(true);
expect(ordered.map((variant) => variant?.costs?.[0]?.mode === 'current_hp_ratio'
? variant.costs[0].ratio
: undefined)).toEqual([...costs]);
it.each(
(['mirror-karma', 'demon-crossing'] as PathId[]).flatMap((pathId) =>
WUXIANG_TECHNIQUE_IDS.map((abilityId) => ({ pathId, abilityId }))),
)('$pathId/$abilityId 保持稳定 ID、目标、费用和冷却', ({ pathId, abilityId }) => {
const expected = abilityTable[pathId][abilityId];
const config = resolveSectAbility({ sect: state(pathId), realm: '化神', abilityId }).config;
expect(config.slug).toBe(`sect.wuxiang.${abilityId}`);
expect(config.name).toBe(expected.names[0]);
expect(config.targetPolicy).toEqual({ team: expected.target, scope: 'single' });
expect(config.costs).toEqual([{
resource: 'hp',
mode: 'current_hp_ratio',
ratio: expected.cost,
minimum: 1,
retain: 1,
}]);
expect(config.cooldown).toBe(expected.cooldown);
expect(config.effectPlans?.map((plan) => plan.name)).toEqual([
expected.names[2],
expected.names[1],
]);
});
it.each(['mirror-karma', 'demon-crossing'] as const)(
'%s 的转相只在两种受限效果计划间选择,费用按战意条件固定',
(pathId) => {
const config = resolveSectAbility({
sect: state(pathId), realm: '化神', abilityId: 'turn-form',
}).config;
expect(config.targetPolicy).toEqual({ team: 'self', scope: 'single' });
expect(config.cooldown).toBe(0);
expect(config.costs?.map((cost) => cost.mode === 'current_hp_ratio' ? cost.ratio : 0))
.toEqual([0.04, 0.08]);
expect(config.effectPlans?.map((plan) => ({ name: plan.name, layers: plan.layerIds })))
.toEqual([
{ name: '一念无间', layers: ['demon', 'formless'] },
{ name: '魔相入身', layers: ['demon'] },
]);
},
);
it('魔心无相的数值条款默认80%,佛魔同炉恢复100%,离散状态操作不变', () => {
const variant = (nodes: string[], abilityId: string) =>
resolveSectAbility({ sect: state('demon-crossing', nodes), realm: '化神', abilityId })
.config.variants!.find((entry) => entry.id === `demon.formless.${abilityId}`)!;
const damageCoefficients = (effects: NonNullable<ReturnType<typeof variant>['effects']>) =>
effects.filter((effect) => effect.type === 'damage').map((effect) =>
effect.type === 'damage' ? effect.params.value.coefficient : undefined);
it('技能详情以佛相主体、魔相追加、无相追加分段展示,不暴露内部层 ID', () => {
const flower = resolveSectAbility({
sect: state('mirror-karma'), realm: '化神', abilityId: 'flower-heart',
});
expect(flower.detailRows).toContain('效果计划「心花两忘」:佛相 + 魔相 + 无相');
expect(flower.detailRows).toContain('效果计划「花落问罪」:佛相 + 魔相');
expect(flower.detailRows).toContain('佛相主体:伤害:相当于60%物攻');
expect(flower.detailRows).toContain('魔相追加:命中后:每层追加:相当于35%物攻');
expect(flower.detailRows).toContain('无相追加:伤害:相当于30%物攻');
expect(flower.detailRows.some((row) => row.includes('demon + formless'))).toBe(false);
expect(damageCoefficients(variant([], 'three-knocks').effects!)).toEqual([0.36, 0.36, 0.64]);
expect(damageCoefficients(variant(['demon-one-furnace'], 'three-knocks').effects!)).toEqual([0.45, 0.45, 0.8]);
expect(damageCoefficients(variant([], 'reed-crossing').effects!)).toEqual([0.8, 0.4]);
expect(damageCoefficients(variant(['demon-one-furnace'], 'reed-crossing').effects!)).toEqual([1, 0.5]);
const baselineSkandhas = variant([], 'five-skandhas').effects!;
const completeSkandhas = variant(['demon-one-furnace'], 'five-skandhas').effects!;
expect(baselineSkandhas.filter((effect) => effect.type === 'status_transfer')).toHaveLength(2);
expect(completeSkandhas.filter((effect) => effect.type === 'status_transfer')).toHaveLength(2);
const mirrorSkandhas = resolveSectAbility({
sect: state('mirror-karma'), realm: '化神', abilityId: 'five-skandhas',
});
expect(mirrorSkandhas.detailRows).toContain(
'佛相主体:命中后:驱散:目标1个正面状态',
);
const demonSkandhas = resolveSectAbility({
sect: state('demon-crossing'), realm: '化神', abilityId: 'five-skandhas',
});
expect(demonSkandhas.detailRows).toContain(
'佛相主体:施展后:净化:自身1个负面状态',
);
});
it('现报无迟同步强化无相式中的现报伤害组成', () => {
const noform = (abilityId: string) => resolveSectAbility({
sect: state('mirror-karma', ['mirror-fast-fruit']), realm: '化神', abilityId,
}).config.variants!.find((entry) => entry.id === `mirror.formless.${abilityId}`)!;
const directCoefficient = (abilityId: string) => {
const damage = noform(abilityId).effects!.find((effect) => effect.type === 'damage');
return damage?.type === 'damage' ? damage.params.value.coefficient : undefined;
};
const knocks = noform('three-knocks').effects!.find((effect) =>
effect.type === 'consume_status_trigger');
const observeDemon = resolveSectAbility({
sect: state('mirror-karma', ['mirror-fast-fruit']),
realm: '化神',
abilityId: 'observe-calamity',
}).config.variants!.find((entry) => entry.id === 'mirror.demon.1.observe-calamity')!;
const observePresentSegments = observeDemon.effects!
.filter((effect) => effect.type === 'consume_status_trigger')
.flatMap((effect) => effect.type === 'consume_status_trigger'
? effect.params.effects.filter((child) => child.type === 'damage')
: [])
.map((effect) => effect.type === 'damage' ? effect.params.value.coefficient : undefined);
it('佛魔同炉只逐门修改六项明确的无相 C 字段', () => {
const formlessEffects = (abilityId: string, nodes: string[] = []) =>
resolveSectAbility({
sect: state('demon-crossing', nodes), realm: '化神', abilityId,
}).config.effectLayers?.find((layer) => layer.id === 'formless')?.effects ?? [];
const base = Object.fromEntries(WUXIANG_TECHNIQUE_IDS.map((abilityId) => [
abilityId,
formlessEffects(abilityId),
]));
const furnace = Object.fromEntries(WUXIANG_TECHNIQUE_IDS.map((abilityId) => [
abilityId,
formlessEffects(abilityId, ['demon-one-furnace']),
]));
expect(directCoefficient('flower-heart')).toBe(1.3);
expect(directCoefficient('observe-calamity')).toBe(1.55);
expect(knocks?.type === 'consume_status_trigger'
? knocks.params.effects[0]
: undefined).toMatchObject({ type: 'damage', params: { value: { coefficient: 0.65 } } });
expect(observePresentSegments).toEqual([0.42, 0.42, 0.42]);
expect(base['flower-heart'][0]).toMatchObject({
type: 'damage', params: { dynamicScalars: [{ coefficientCap: 0.4 }] },
});
expect(furnace['flower-heart'][0]).toMatchObject({
type: 'damage', params: { dynamicScalars: [{ coefficientCap: 0.6 }] },
});
expect(base['blood-tide'][0]).toMatchObject({
type: 'heal', params: { value: { targetMaxHpRatio: 0.05 } },
});
expect(furnace['blood-tide'][0]).toMatchObject({
type: 'heal', params: { value: { targetMaxHpRatio: 0.08 } },
});
expect(base['three-knocks'][0]).toMatchObject({
type: 'damage', params: { value: { coefficient: 0.65 } },
});
expect(furnace['three-knocks'][0]).toMatchObject({
type: 'damage', params: { value: { coefficient: 0.85 } },
});
expect(base['observe-calamity'][0]).toMatchObject({
type: 'shield', params: { value: { targetMaxHpRatio: 0.08 } },
});
expect(furnace['observe-calamity'][0]).toMatchObject({
type: 'shield', params: { value: { targetMaxHpRatio: 0.12 } },
});
expect(base['five-skandhas'].some((effect) => effect.type === 'heal')).toBe(false);
expect(furnace['five-skandhas']).toContainEqual(expect.objectContaining({
type: 'heal', params: expect.objectContaining({
value: expect.objectContaining({ targetMaxHpRatio: 0.05 }),
}),
}));
expect(base['reed-crossing'][0]).toMatchObject({
type: 'heal', params: { value: { targetMaxHpRatio: 0.05 } },
});
expect(furnace['reed-crossing'][0]).toMatchObject({
type: 'heal', params: { value: { targetMaxHpRatio: 0.08 } },
});
});
it('转相在3至5战意显示魔相入身,6战意显示一念无间并占用行动', () => {
const detail = resolveSectAbility({ sect: state('mirror-karma'), realm: '化神', abilityId: 'turn-form' });
expect(detail.config.variants?.map((variant) => variant.name)).toEqual(['一念无间', '魔相入身']);
expect(detail.config.variants?.map((variant) => variant.costs?.[0])).toEqual([
expect.objectContaining({ ratio: 0.08 }),
expect.objectContaining({ ratio: 0.04 }),
]);
expect(detail.config.variants?.every((variant) => variant.castEffects?.some((effect) => effect.type === 'ability_mode'))).toBe(true);
});
it('节点改变循环规则投影,不改变固定冷却', () => {
const selected = ['mirror-vow-body', 'mirror-fourth-knock', 'mirror-formless-two'];
const sect = state('mirror-karma', selected);
const flower = resolveSectAbility({ sect, realm: '化神', abilityId: 'flower-heart' });
const knocks = resolveSectAbility({ sect, realm: '化神', abilityId: 'three-knocks' });
expect(flower.config.cooldown).toBe(0);
expect(knocks.config.cooldown).toBe(2);
expect(flower.config.variants?.some((variant) => variant.id === 'mirror.buddha.first.flower-heart')).toBe(true);
const noform = flower.config.variants?.find((variant) => variant.id === 'mirror.formless.flower-heart');
expect(noform?.costs?.[0]).toMatchObject({ ratio: 0.1 });
});
it('36个节点逐项编译出对应循环行为,且六门共享冷却不随形态改变', () => {
const expectedCooldowns = new Map([
['flower-heart', 0], ['blood-tide', 3], ['three-knocks', 2],
['observe-calamity', 4], ['five-skandhas', 3], ['reed-crossing', 5],
]);
const signatures: Record<string, unknown[]> = {
'mirror-vow-body': [{ id: 'mirror.buddha.first.flower-heart', costs: [{ ratio: 0.07 }] }, { key: 'sect.wuxiang.mirror.vow-round' }],
'mirror-guest-in-mirror': [{ id: 'sect.wuxiang.mirror.guest-round', budget: { reset: 'round' } }],
'mirror-fruit-in-time': [{ maxHpRatioPerAction: 0.16 }],
'mirror-loud-flower': [{ id: 'sect.wuxiang.mirror.heart-vow.trigger', effects: [{ params: { value: 0.18 } }] }],
'mirror-welcome-tide': [{ type: 'damage_defer', params: { ratio: 0.4 } }],
'mirror-fourth-knock': [{ id: 'sect.wuxiang.mirror.karma-door', maxLayers: 4 }],
'mirror-see-guest': [{ id: 'sect.wuxiang.mirror.observe.first', effects: [{ params: { value: 0.5 } }] }],
'mirror-skandhas-mark': [{ id: 'mirror.buddha.five-skandhas', effects: [{ type: 'status_transfer', params: { effects: [{ type: 'apply_buff' }, { type: 'apply_buff' }] } }] }],
'mirror-carry-karma': [{ type: 'damage_cap', params: { maxHpRatio: 0.25 } }],
'mirror-form-beyond': [{ id: 'sect.wuxiang.mirror.stillness.reduce', effects: [{ params: { value: 0.35 } }] }],
'mirror-back-demon': [{ id: 'mirror.demon.1.flower-heart', effects: [{ type: 'ability_lock' }] }],
'mirror-formless-two': [{ id: 'mirror.formless.flower-heart', costs: [{ ratio: 0.1 }] }, { operation: 'set', layers: 2 }],
'mirror-full-light': [{ id: 'sect.wuxiang.mirror.reflect', effects: [{ type: 'reflect', params: { ratio: 0.05 }, conditions: [{ type: 'buff_layer_at_least', params: { value: 3 } }] }] }],
'mirror-fast-fruit': [{ id: 'mirror.demon.1.flower-heart', effects: [{ type: 'damage', params: { value: { coefficient: 0.55 } } }] }],
'mirror-return-source': [{ type: 'consume_status_trigger', params: { effects: [{ type: 'heal', params: { value: { targetMaxHpRatio: 0.02 } } }] } }],
'mirror-not-platform': [{ id: 'sect.wuxiang.mirror.full-reduce', effects: [{ params: { value: 0.1 } }] }],
'mirror-all-karma': [{ type: 'consume_status_trigger', params: { consume: 2, scaleNumericEffectsByLayer: true } }],
'mirror-return-thought': [{ id: 'mirror.buddha.return-thought.flower-heart', costs: [{ ratio: 0.08 }] }, { resourceId: 'sect.wuxiang.war-intent', amount: 2 }],
'demon-blood-oil': [{ id: 'demon.buddha.first.flower-heart', costs: [{ ratio: 0.08 }] }, { key: 'sect.wuxiang.demon.oil-round' }],
'demon-three-shores': [{ id: 'sect.wuxiang.demon.threshold.0.7', effects: [{ type: 'shield', params: { value: { targetMaxHpRatio: 0.02 } } }] }],
'demon-bone-tide': [{ id: 'sect.wuxiang.demon.blood-store', effects: [{ type: 'damage_memory', params: { maxStoredValue: { coefficient: 0.22 } } }] }],
'demon-flower-inward': [{ id: 'sect.wuxiang.demon.heart-gap.damage', effects: [{ params: { value: 0.25 } }] }],
'demon-no-return-tide': [{ id: 'demon.entry.blood-tide', effects: [{ type: 'damage_memory', params: { ratio: 2.5 } }] }],
'demon-third-outside': [{ id: 'demon.buddha.three-knocks', effects: [{ conditions: [{ params: { value: 0.55, timing: 'cast' } }] }] }],
'demon-slow-fire': [{ id: 'demon.buddha.observe-calamity', effects: [{ type: 'apply_buff', params: { buffConfig: { listeners: [{ effects: [{ type: 'damage_defer', params: { ratio: 0.5 } }] }] } } }] }],
'demon-skandhas-fuel': [{ id: 'demon.entry.five-skandhas', effects: [{ type: 'status_transfer', params: { maxCount: 3 } }] }],
'demon-short-reed': [{ id: 'demon.buddha.reed-crossing', effects: [{ type: 'apply_buff', params: { buffConfig: { listeners: [{ effects: [{ type: 'damage_cap', params: { maxHpRatio: 0.3 } }] }] } } }] }],
'demon-first-thought': [{ id: 'demon.entry.three-knocks', effects: [{ type: 'damage', params: { value: { coefficient: 0.5175 } } }] }],
'demon-second-shore': [{ id: 'demon.finish.three-knocks', effects: [{ type: 'damage', params: { value: { coefficient: 0.96 } } }] }, { id: 'sect.wuxiang.demon.blood-finish-lifesteal', effects: [{ params: { ratio: 0.23 } }] }],
'demon-two-gates': [{ id: 'demon.finish.flower-heart.different', costs: [{ ratio: 0.025 }] }],
'demon-body-breaks': [{ id: 'sect.wuxiang.demon.body-breaks.hp', eventType: 'HpChangedEvent' }, { attrType: 'controlResistance', value: 0.3 }],
'demon-blood-empty': [{ id: 'sect.wuxiang.demon.threshold.0.25', effects: [{ type: 'shield', params: { value: { targetMaxHpRatio: 0.06 } } }] }],
'demon-leave-boat': [{ id: 'demon.buddha.leave-boat.flower-heart', costs: [{ ratio: 0.03 }] }],
'demon-one-furnace': [{ id: 'demon.formless.three-knocks', effects: [{ type: 'damage', params: { value: { coefficient: 0.8 } } }] }],
'demon-no-gap': [{ id: 'sect.wuxiang.demon.enter.reduce', effects: [{ params: { value: 0 } }] }, { maxHpRatioPerAction: 0.12 }],
'demon-look-back': [{ id: 'demon.finish.flower-heart', effects: [{ type: 'heal', params: { value: { targetMaxHpRatio: 0.05 } } }] }, { id: 'demon.formless.flower-heart', effects: [{ type: 'heal', params: { value: { targetMaxHpRatio: 0.05 } } }] }],
};
it('36个节点都产生明确配置差异,且不重新引入已删除的特殊机制', () => {
const forbidden = [
'variants',
'status_transfer',
'damage_cap',
'scaleNumericEffectsByLayer',
'ability_variant_is',
'ability_mode_ability_differs',
];
const signatures = new Set<string>();
for (const path of WUXIANG_MODULE.definition.paths) {
const pathId = path.id as 'mirror-karma' | 'demon-crossing';
const pathId = path.id as PathId;
const abilityIds = [
...WUXIANG_TECHNIQUE_IDS,
'turn-form',
pathId === 'mirror-karma' ? 'mirror-core' : 'demon-core',
];
const compileAll = (nodes: string[]) => abilityIds.map((abilityId) =>
resolveSectAbility({ sect: state(pathId, nodes), realm: '化神', abilityId }).config);
const baseline = JSON.stringify(compileAll([]));
for (const node of path.nodes) {
const configs = [...WUXIANG_TECHNIQUE_IDS, 'turn-form', pathId === 'mirror-karma' ? 'mirror-core' : 'demon-core']
.map((abilityId) => resolveSectAbility({ sect: state(pathId, [node.id]), realm: '化神', abilityId }).config);
for (const signature of signatures[node.id] ?? []) {
expect(
containsSubset(configs, signature),
`${path.name}/${node.name}缺少对应的行为契约 ${JSON.stringify(signature)}`,
).toBe(true);
const serialized = JSON.stringify(compileAll([node.id]));
expect(serialized, `${path.name}/${node.name}必须改变战斗投影`).not.toBe(baseline);
const signature = `${path.id}:${serialized}`;
expect(signatures.has(signature), `${path.name}/${node.name}必须有唯一配置签名`).toBe(false);
signatures.add(signature);
for (const token of forbidden) {
expect(serialized, `${path.name}/${node.name}不应包含 ${token}`).not.toContain(token);
}
}
for (const [abilityId, cooldown] of expectedCooldowns) {
expect(resolveSectAbility({ sect: state(pathId), realm: '化神', abilityId }).config.cooldown).toBe(cooldown);
}
const representative = path.layers.map((layer) =>
path.nodes.find((node) => node.layerId === layer.id)!.id);
expect(() => compileAll(representative)).not.toThrow();
const sameLayer = path.nodes.filter(
(node) => node.layerId === path.layers[0].id,
).slice(0, 2).map((node) => node.id);
expect(() => compileAll(sameLayer)).toThrow(/只能选择一个节点/);
}
expect(signatures).toHaveLength(36);
});
});
@@ -2,11 +2,7 @@ import type { ActiveSkill } from '@shared/engine/battle-v5/abilities/ActiveSkill
import { BasicAttack } from '@shared/engine/battle-v5/abilities/BasicAttack';
import { StackRule } from '@shared/engine/battle-v5/buffs/Buff';
import { EventBus } from '@shared/engine/battle-v5/core/EventBus';
import type {
AbilityCostPaidEvent,
DamageRequestEvent,
DamageTakenEvent,
} from '@shared/engine/battle-v5/core/events';
import type { DamageRequestEvent, DamageTakenEvent } from '@shared/engine/battle-v5/core/events';
import {
beginRuntimeAction,
clearAbilityMode,
@@ -21,19 +17,35 @@ import { Unit } from '@shared/engine/battle-v5/units/Unit';
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import { projectSectCombat, resolveSectAbility } from '../..';
import type { CultivatorSectState } from '../../../core';
import { WUXIANG_FORM_MODE, WUXIANG_KARMA_BUFF, WUXIANG_WAR_INTENT } from '..';
import {
WUXIANG_FORM_MODE,
WUXIANG_KARMA_BUFF,
WUXIANG_WAR_INTENT,
} from '..';
function state(pathId: 'mirror-karma' | 'demon-crossing', nodes: string[] = []): CultivatorSectState {
type PathId = 'mirror-karma' | 'demon-crossing';
function state(pathId: PathId, nodes: string[] = []): CultivatorSectState {
return {
membershipId: 'runtime', sectId: 'wuxiang', status: 'active', contribution: 0,
configVersion: 1, activePathId: pathId,
membershipId: 'runtime',
sectId: 'wuxiang',
status: 'active',
contribution: 0,
configVersion: 2,
activePathId: pathId,
methods: {
'wuxiang-canon': 5, 'blood-lotus': 3, 'white-bone': 3,
'wrathful-ming': 3, 'six-senses': 3, 'reed-crossing-method': 3,
'wuxiang-canon': 5,
'blood-lotus': 3,
'white-bone': 3,
'wrathful-ming': 3,
'six-senses': 3,
'reed-crossing-method': 3,
},
paths: [{
pathId, unlockedLayerIds: ['1', '2', '3', '4', '5', 'ultimate'],
tacticId: pathId === 'mirror-karma' ? 'guard' : 'trial-fire', activeMeridianSlot: 1,
pathId,
unlockedLayerIds: ['1', '2', '3', '4', '5', 'ultimate'],
tacticId: pathId === 'mirror-karma' ? 'guard' : 'trial-fire',
activeMeridianSlot: 1,
meridianLoadouts: [
{ slot: 1, nodeIds: nodes, version: 1 },
{ slot: 2, nodeIds: [], version: 1 },
@@ -46,21 +58,32 @@ function state(pathId: 'mirror-karma' | 'demon-crossing', nodes: string[] = []):
function unit(id: string): Unit {
return new Unit(id, id, {
[AttributeType.VITALITY]: 100, [AttributeType.SPIRIT]: 100,
[AttributeType.WISDOM]: 100, [AttributeType.SPEED]: 100,
[AttributeType.VITALITY]: 100,
[AttributeType.SPIRIT]: 100,
[AttributeType.WISDOM]: 100,
[AttributeType.SPEED]: 100,
[AttributeType.WILLPOWER]: 100,
});
}
function install(pathId: 'mirror-karma' | 'demon-crossing', nodes: string[] = []) {
function install(pathId: PathId, nodes: string[] = []) {
const projection = projectSectCombat({ sect: state(pathId, nodes), realm: '化神' })!;
const owner = unit('owner');
const enemy = unit('enemy');
for (const resource of projection.resources) owner.combatResources.define(resource);
const defaultAttack = AbilityFactory.create(projection.defaultAttack!) as ActiveSkill;
owner.abilities.setDefaultAttack(defaultAttack);
for (const config of projection.abilities) owner.abilities.addAbility(AbilityFactory.create(config));
const skill = (id: string) => owner.abilities.getAbility(`sect.wuxiang.${id}`) as ActiveSkill;
for (const config of projection.abilities) {
owner.abilities.addAbility(AbilityFactory.create(config));
}
const skill = (id: string) => {
const installed = owner.abilities.getAbility(`sect.wuxiang.${id}`);
if (installed) return installed as ActiveSkill;
const config = resolveSectAbility({ sect: state(pathId, nodes), realm: '化神', abilityId: id }).config;
const created = AbilityFactory.create(config) as ActiveSkill;
owner.abilities.addAbility(created);
return created;
};
return { owner, enemy, defaultAttack, skill };
}
@@ -69,353 +92,450 @@ function cast(skill: ActiveSkill, caster: Unit, target: Unit, hit = true) {
skill.execute({ caster, target, shouldApplyEffects: hit });
}
function damageSegments(skill: ActiveSkill, action: () => void): number[] {
const segments: number[] = [];
const handler = EventBus.instance.subscribe<DamageRequestEvent>('DamageRequestEvent', (event) => {
if (event.ability !== skill) return;
const segment = event.damageComponents?.find((component) =>
component.segmentMultiplier !== undefined)?.segmentMultiplier;
if (segment !== undefined) segments.push(segment);
});
action();
EventBus.instance.unsubscribe('DamageRequestEvent', handler);
return segments;
}
function addLayeredBuff(
target: Unit,
id: string,
name: string,
type: BuffType,
layers: number,
): void {
const template = BuffFactory.create({
id,
name,
type,
duration: 4,
stackRule: StackRule.STACK_LAYER,
maxLayers: Math.max(layers, 1),
});
for (let index = 0; index < layers; index += 1) {
target.buffs.addBuff(index === 0 ? template : template.clone(), target);
}
}
describe('无相禅宗三相循环', () => {
beforeEach(() => EventBus.instance.reset());
afterEach(() => EventBus.instance.reset());
it('魔相只推进成功的两门宗门神通,期间停止获得战意', () => {
it('3至5点战意进入两次魔相;未命中和非宗门技能都不消费次数', () => {
const { owner, enemy, defaultAttack, skill } = install('demon-crossing');
owner.combatResources.modify(WUXIANG_WAR_INTENT, 3);
cast(skill('turn-form'), owner, owner);
expect(readAbilityMode(owner, WUXIANG_FORM_MODE)).toMatchObject({ mode: 'demon', phase: 1, remainingUses: 2 });
expect(readAbilityMode(owner, WUXIANG_FORM_MODE)).toMatchObject({
mode: 'demon',
remainingUses: 2,
});
expect(owner.combatResources.getCurrent(WUXIANG_WAR_INTENT)).toBe(0);
cast(defaultAttack, owner, enemy, false);
expect(readAbilityMode(owner, WUXIANG_FORM_MODE)).toMatchObject({ phase: 1, remainingUses: 2 });
expect(owner.buffs.getAllBuffs().some((buff) => buff.name === '入魔')).toBe(true);
const fist = new BasicAttack();
fist.setOwner(owner);
fist.setActive(true);
cast(fist, owner, enemy);
expect(readAbilityMode(owner, WUXIANG_FORM_MODE)).toMatchObject({ phase: 1, remainingUses: 2 });
expect(owner.buffs.getAllBuffs().some((buff) => buff.name === '入魔')).toBe(true);
expect(readAbilityMode(owner, WUXIANG_FORM_MODE)).toMatchObject({ remainingUses: 2 });
const ordinaryAttack = new BasicAttack();
ordinaryAttack.setOwner(owner);
ordinaryAttack.setActive(true);
cast(ordinaryAttack, owner, enemy);
expect(readAbilityMode(owner, WUXIANG_FORM_MODE)).toMatchObject({ remainingUses: 2 });
cast(defaultAttack, owner, enemy);
expect(readAbilityMode(owner, WUXIANG_FORM_MODE)).toMatchObject({ phase: 2, remainingUses: 1 });
expect(owner.buffs.getAllBuffs().some((buff) => buff.name === '入魔')).toBe(false);
expect(owner.combatResources.getCurrent(WUXIANG_WAR_INTENT)).toBe(0);
expect(readAbilityMode(owner, WUXIANG_FORM_MODE)).toMatchObject({ remainingUses: 1 });
cast(skill('three-knocks'), owner, enemy);
expect(readAbilityMode(owner, WUXIANG_FORM_MODE)).toBeUndefined();
expect(owner.combatResources.getCurrent(WUXIANG_WAR_INTENT)).toBe(0);
});
it('明镜佛相只在宗门神通成功结算后获得战意', () => {
const { owner, enemy, defaultAttack } = install('mirror-karma');
cast(defaultAttack, owner, enemy, false);
expect(owner.combatResources.getCurrent(WUXIANG_WAR_INTENT)).toBe(0);
cast(defaultAttack, owner, enemy, true);
expect(owner.combatResources.getCurrent(WUXIANG_WAR_INTENT)).toBe(1);
});
it('明镜魔相停止业痕、战意与即时反伤', () => {
const { owner, enemy, skill } = install('mirror-karma');
owner.combatResources.modify(WUXIANG_WAR_INTENT, 3);
cast(skill('turn-form'), owner, owner);
const reflected: DamageRequestEvent[] = [];
EventBus.instance.subscribe<DamageRequestEvent>('DamageRequestEvent', (event) => {
if (event.damageSource === DamageSource.REFLECT) reflected.push(event);
});
beginRuntimeAction(enemy);
EventBus.instance.publish<DamageTakenEvent>({
type: 'DamageTakenEvent', timestamp: Date.now(), caster: enemy, target: owner,
damageSource: DamageSource.DIRECT, damageType: DamageType.PHYSICAL,
damageTaken: 100, beforeHp: owner.getCurrentHp(), remainHp: owner.getCurrentHp(),
isLethal: false,
});
expect(owner.buffs.getAllBuffs().find((buff) => buff.id === WUXIANG_KARMA_BUFF)).toBeUndefined();
expect(owner.combatResources.getCurrent(WUXIANG_WAR_INTENT)).toBe(0);
expect(reflected).toHaveLength(0);
});
it('明镜进入魔相后遗留业门不会自动反伤或消耗', () => {
const { owner, enemy, skill } = install('mirror-karma');
owner.combatResources.modify(WUXIANG_WAR_INTENT, 3);
cast(skill('three-knocks'), owner, enemy);
const doorId = 'sect.wuxiang.mirror.karma-door';
expect(enemy.buffs.getAllBuffs().find((buff) => buff.id === doorId)?.getLayer()).toBe(3);
cast(skill('turn-form'), owner, owner);
const reflected: DamageRequestEvent[] = [];
EventBus.instance.subscribe<DamageRequestEvent>('DamageRequestEvent', (event) => {
if (event.damageSource === DamageSource.REFLECT) reflected.push(event);
});
EventBus.instance.publish<DamageTakenEvent>({
type: 'DamageTakenEvent', timestamp: Date.now(), caster: enemy, target: owner,
damageSource: DamageSource.DIRECT, damageType: DamageType.PHYSICAL,
damageTaken: 100, beforeHp: owner.getCurrentHp(), remainHp: owner.getCurrentHp(),
isLethal: false,
});
expect(reflected).toHaveLength(0);
expect(enemy.buffs.getAllBuffs().find((buff) => buff.id === doorId)?.getLayer()).toBe(3);
});
it('焚尽五蕴默认入魔只转移1个减益,五蕴作薪提高至3个', () => {
const execute = (nodes: string[]) => {
const sect = state('demon-crossing', nodes);
const owner = unit(`owner-${nodes.length}`);
const enemy = unit(`enemy-${nodes.length}`);
const config = resolveSectAbility({ sect, realm: '化神', abilityId: 'five-skandhas' }).config;
const skill = AbilityFactory.create(config) as ActiveSkill;
skill.setOwner(owner);
skill.setActive(true);
setAbilityMode(owner, {
key: WUXIANG_FORM_MODE, mode: 'demon', phase: 1,
remainingUses: 2, displayName: '魔相',
});
for (let index = 0; index < 3; index += 1) {
owner.buffs.addBuff(BuffFactory.create({
id: `negative-${index}`, name: `减益${index}`, type: BuffType.DEBUFF,
duration: 3, stackRule: StackRule.OVERRIDE,
}), enemy);
}
cast(skill, owner, enemy);
return enemy.buffs.getAllBuffs().filter((buff) => buff.type === BuffType.DEBUFF).length;
};
expect(execute([])).toBe(1);
expect(execute(['demon-skandhas-fuel'])).toBe(3);
});
it('万业同门按实际消费两层业痕递归强化业门引爆', () => {
const { owner, enemy, skill } = install('mirror-karma', ['mirror-all-karma']);
const knocks = skill('three-knocks');
cast(knocks, owner, enemy);
knocks.resetCooldown();
setAbilityMode(owner, {
key: WUXIANG_FORM_MODE, mode: 'demon', phase: 2,
remainingUses: 1, displayName: '魔相·现报',
});
const karma = BuffFactory.create({
id: WUXIANG_KARMA_BUFF, name: '业痕', type: BuffType.BUFF,
duration: -1, stackRule: StackRule.STACK_LAYER, maxLayers: 3,
});
owner.buffs.addBuff(karma, owner);
owner.buffs.addBuff(karma.clone(), owner);
const segments: number[] = [];
EventBus.instance.subscribe<DamageRequestEvent>('DamageRequestEvent', (event) => {
const segment = event.damageComponents?.find((component) =>
component.segmentMultiplier !== undefined)?.segmentMultiplier;
if (event.caster === owner && segment !== undefined) segments.push(segment);
});
cast(knocks, owner, enemy);
expect(segments).toEqual([0.75, 0.64, 0.64, 0.64]);
expect(owner.buffs.getAllBuffIds()).not.toContain(WUXIANG_KARMA_BUFF);
});
it('6点战意优先进入无相且只消费下一门神通一次', () => {
it('6点战意优先进入一次无相,下一门神通执行 A+B+C 后恢复佛相', () => {
const { owner, enemy, defaultAttack, skill } = install('mirror-karma');
owner.combatResources.modify(WUXIANG_WAR_INTENT, 6);
expect(skill('turn-form').name).toBe('一念无间');
cast(skill('turn-form'), owner, owner);
expect(readAbilityMode(owner, WUXIANG_FORM_MODE)).toMatchObject({ mode: 'formless', remainingUses: 1 });
expect(readAbilityMode(owner, WUXIANG_FORM_MODE)).toMatchObject({
mode: 'formless',
remainingUses: 1,
});
expect(defaultAttack.name).toBe('心花两忘');
cast(defaultAttack, owner, enemy);
expect(readAbilityMode(owner, WUXIANG_FORM_MODE)).toBeUndefined();
expect(defaultAttack.name).toBe('拈花叩心');
expect(owner.buffs.getAllBuffs().find((buff) => buff.id === WUXIANG_KARMA_BUFF)?.getLayer()).toBe(1);
});
it('默认神通进入战斗快照,并显示当前变体名称与实时气血成本', () => {
const { owner, skill } = install('mirror-karma');
owner.combatResources.modify(WUXIANG_WAR_INTENT, 6);
cast(skill('turn-form'), owner, owner);
const recorder = new BattleStateRecorder();
recorder.record('battle_init', 0, [owner]);
const cooldowns = recorder.getFrames()[0].units[owner.id].cooldowns;
const defaultSkill = cooldowns.find((entry) => entry.skillId === 'sect.wuxiang.flower-heart');
expect(defaultSkill?.skillName).toBe('心花两忘');
expect(defaultSkill?.description).toContain('设戒');
expect(defaultSkill?.costs?.[0]).toMatchObject({
resource: 'hp', mode: 'current_hp_ratio', ratio: 0.08,
resolvedAmount: Math.ceil(owner.getCurrentHp() * 0.08),
});
});
it('魔心的70%气血线只奖励一次,治疗后重复跨越不再获益', () => {
const { owner, skill } = install('demon-crossing');
const blood = skill('blood-tide');
const startingHp = Math.floor(owner.getMaxHp() * 0.71);
owner.setHp(startingHp);
cast(blood, owner, owner);
expect(owner.combatResources.getCurrent(WUXIANG_WAR_INTENT)).toBe(2);
owner.setHp(startingHp);
blood.resetCooldown();
cast(blood, owner, owner);
expect(owner.combatResources.getCurrent(WUXIANG_WAR_INTENT)).toBe(3);
});
it('身坏心明在佛相低气血时实时生效,治疗或进入任一非佛相后立即移除', () => {
const { owner, enemy, skill } = install('demon-crossing', ['demon-body-breaks']);
const buffId = 'sect.wuxiang.demon.body-breaks';
owner.setHp(Math.floor(owner.getMaxHp() * 0.25));
expect(owner.buffs.getAllBuffIds()).toContain(buffId);
owner.heal(Math.floor(owner.getMaxHp() * 0.5));
expect(owner.buffs.getAllBuffIds()).not.toContain(buffId);
owner.setHp(Math.floor(owner.getMaxHp() * 0.25));
owner.combatResources.modify(WUXIANG_WAR_INTENT, 3);
cast(skill('turn-form'), owner, owner);
expect(owner.buffs.getAllBuffIds()).not.toContain(buffId);
clearAbilityMode(owner, WUXIANG_FORM_MODE);
owner.setHp(Math.floor(owner.getMaxHp() * 0.25));
expect(owner.buffs.getAllBuffIds()).toContain(buffId);
owner.combatResources.modify(WUXIANG_WAR_INTENT, 6);
cast(skill('turn-form'), owner, owner);
expect(readAbilityMode(owner, WUXIANG_FORM_MODE)).toMatchObject({ mode: 'formless' });
expect(owner.buffs.getAllBuffIds()).not.toContain(buffId);
});
it.each([
['mirror-karma', 'mirror-vow-body'],
['demon-crossing', 'demon-blood-oil'],
] as const)('%s每轮首次佛相成本提高2个百分点且只多得1战意', (pathId, nodeId) => {
const { owner, enemy, defaultAttack } = install(pathId, [nodeId]);
const paid: AbilityCostPaidEvent[] = [];
EventBus.instance.subscribe<AbilityCostPaidEvent>('AbilityCostPaidEvent', (event) => {
if (event.ability === defaultAttack) paid.push(event);
});
const firstHp = owner.getCurrentHp();
cast(defaultAttack, owner, enemy);
const secondHp = owner.getCurrentHp();
cast(defaultAttack, owner, enemy);
const baseCost = pathId === 'mirror-karma' ? 0.05 : 0.06;
expect(paid.map((event) => event.hpPaid)).toEqual([
Math.ceil(firstHp * (baseCost + 0.02)),
Math.ceil(secondHp * baseCost),
]);
expect(owner.combatResources.getCurrent(WUXIANG_WAR_INTENT)).toBe(3);
EventBus.instance.publish({ type: 'RoundStartEvent', timestamp: Date.now(), turn: 2 });
const nextRoundHp = owner.getCurrentHp();
cast(defaultAttack, owner, enemy);
expect(paid[2].hpPaid).toBe(Math.ceil(nextRoundHp * (baseCost + 0.02)));
expect(owner.combatResources.getCurrent(WUXIANG_WAR_INTENT)).toBe(5);
});
it('镜中留客每轮只为首次直接受击额外留下1层业痕', () => {
const { owner, enemy } = install('mirror-karma', ['mirror-guest-in-mirror']);
const hit = () => EventBus.instance.publish<DamageTakenEvent>({
type: 'DamageTakenEvent', timestamp: Date.now(), caster: enemy, target: owner,
damageSource: DamageSource.DIRECT, damageType: DamageType.PHYSICAL,
damageTaken: 100, beforeHp: owner.getCurrentHp(), remainHp: owner.getCurrentHp(),
isLethal: false,
});
beginRuntimeAction(enemy);
hit();
expect(owner.buffs.getAllBuffs().find((buff) => buff.id === WUXIANG_KARMA_BUFF)?.getLayer()).toBe(2);
owner.buffs.removeBuff(WUXIANG_KARMA_BUFF);
beginRuntimeAction(enemy);
hit();
expect(owner.buffs.getAllBuffs().find((buff) => buff.id === WUXIANG_KARMA_BUFF)?.getLayer()).toBe(1);
});
it('两门同渡只在第二门使用不同神通时将冻结气血成本减半', () => {
const { owner, enemy, defaultAttack, skill } = install('demon-crossing', ['demon-two-gates']);
setAbilityMode(owner, {
key: WUXIANG_FORM_MODE, mode: 'demon', phase: 1,
remainingUses: 2, displayName: '魔相·入魔式',
});
cast(defaultAttack, owner, enemy);
const beforeDifferent = owner.getCurrentHp();
const knocks = skill('three-knocks');
cast(knocks, owner, enemy);
expect(beforeDifferent - owner.getCurrentHp()).toBe(Math.ceil(beforeDifferent * 0.04));
setAbilityMode(owner, {
key: WUXIANG_FORM_MODE, mode: 'demon', phase: 1,
remainingUses: 2, displayName: '魔相·入魔式',
});
cast(defaultAttack, owner, enemy);
const beforeSame = owner.getCurrentHp();
cast(defaultAttack, owner, enemy);
expect(beforeSame - owner.getCurrentHp()).toBe(Math.ceil(beforeSame * 0.05));
});
it('渡后留舟令下一门佛相成本减半且不产生战意', () => {
const { owner, enemy, defaultAttack } = install('demon-crossing', ['demon-leave-boat']);
setAbilityMode(owner, {
key: WUXIANG_FORM_MODE, mode: 'demon', phase: 2,
remainingUses: 1, displayName: '魔相·渡厄式',
});
cast(defaultAttack, owner, enemy);
const beforeBuddha = owner.getCurrentHp();
cast(defaultAttack, owner, enemy);
expect(beforeBuddha - owner.getCurrentHp()).toBe(Math.ceil(beforeBuddha * 0.03));
expect(owner.combatResources.getCurrent(WUXIANG_WAR_INTENT)).toBe(0);
expect(owner.buffs.getAllBuffIds()).not.toContain('sect.wuxiang.demon.leave-boat');
expect(owner.buffs.getAllBuffs().find((buff) => buff.id === WUXIANG_KARMA_BUFF)?.getLayer())
.toBe(1);
});
it('回首彼岸只在每次转相的渡厄结算后低血恢复5%', () => {
const { owner, enemy, defaultAttack } = install('demon-crossing', ['demon-look-back']);
const maxHp = owner.getMaxHp();
owner.setHp(Math.floor(maxHp * 0.19));
const finish = () => {
setAbilityMode(owner, {
key: WUXIANG_FORM_MODE, mode: 'demon', phase: 2,
remainingUses: 1, displayName: '魔相·渡厄式',
});
const before = owner.getCurrentHp();
cast(defaultAttack, owner, enemy);
return { before, after: owner.getCurrentHp() };
};
const first = finish();
expect(first.after).toBe(first.before - Math.ceil(first.before * 0.05) + Math.round(maxHp * 0.05));
const beforeBuddha = owner.getCurrentHp();
cast(defaultAttack, owner, enemy);
expect(owner.getCurrentHp()).toBe(beforeBuddha - Math.ceil(beforeBuddha * 0.06));
owner.setHp(Math.floor(maxHp * 0.19));
const second = finish();
expect(second.after).toBe(second.before - Math.ceil(second.before * 0.05) + Math.round(maxHp * 0.05));
});
it('来去一念在无相后返还2战意,下一门佛相增加3个百分点气血成本', () => {
const { owner, enemy, defaultAttack } = install('mirror-karma', ['mirror-return-thought']);
setAbilityMode(owner, {
key: WUXIANG_FORM_MODE, mode: 'formless', phase: 1,
remainingUses: 1, displayName: '无相待发',
it('施法准备后改变形态,不会改变已冻结的本次效果计划', () => {
const { owner, enemy, defaultAttack } = install('mirror-karma');
const requests: DamageRequestEvent[] = [];
EventBus.instance.subscribe<DamageRequestEvent>('DamageRequestEvent', (event) => {
if (event.ability === defaultAttack) requests.push(event);
});
cast(defaultAttack, owner, enemy);
expect(owner.combatResources.getCurrent(WUXIANG_WAR_INTENT)).toBe(2);
const karma = BuffFactory.create({
id: WUXIANG_KARMA_BUFF,
name: '业痕',
type: BuffType.BUFF,
duration: -1,
stackRule: StackRule.STACK_LAYER,
maxLayers: 3,
});
owner.buffs.addBuff(karma, owner);
setAbilityMode(owner, {
key: WUXIANG_FORM_MODE,
mode: 'demon',
remainingUses: 2,
displayName: '魔相',
});
defaultAttack.prepareCast({ caster: owner, target: enemy });
clearAbilityMode(owner, WUXIANG_FORM_MODE);
const beforeBuddha = owner.getCurrentHp();
cast(defaultAttack, owner, enemy);
expect(beforeBuddha - owner.getCurrentHp()).toBe(Math.ceil(beforeBuddha * 0.08));
expect(owner.combatResources.getCurrent(WUXIANG_WAR_INTENT)).toBe(3);
defaultAttack.execute({ caster: owner, target: enemy });
expect(requests.length).toBeGreaterThan(1);
expect(owner.buffs.getAllBuffIds()).not.toContain(WUXIANG_KARMA_BUFF);
});
it('明镜只响应敌方直接伤害,并按敌方行动共享反伤上限', () => {
it('明镜魔相没有业痕时只跳过 B 的消费后效果,A 与完成效果仍完整结算', () => {
const { owner, enemy, defaultAttack } = install('mirror-karma');
setAbilityMode(owner, {
key: WUXIANG_FORM_MODE,
mode: 'demon',
remainingUses: 2,
displayName: '魔相',
});
const segments = damageSegments(defaultAttack, () =>
cast(defaultAttack, owner, enemy));
expect(segments).toEqual([0.6]);
expect(enemy.buffs.getAllBuffIds()).toContain('sect.wuxiang.mirror.heart-vow');
expect(readAbilityMode(owner, WUXIANG_FORM_MODE)).toMatchObject({ remainingUses: 1 });
});
it('免费现报不被当作实际消费,不触发照还来处治疗', () => {
const { owner, enemy, skill } = install('mirror-karma', [
'mirror-back-demon',
'mirror-return-source',
]);
owner.setHp(Math.floor(owner.getMaxHp() * 0.5));
owner.combatResources.modify(WUXIANG_WAR_INTENT, 3);
const beforeTurn = owner.getCurrentHp();
cast(skill('turn-form'), owner, owner);
const afterTurn = beforeTurn - Math.ceil(beforeTurn * 0.04);
expect(owner.getCurrentHp()).toBe(afterTurn);
cast(skill('flower-heart'), owner, enemy);
expect(owner.getCurrentHp()).toBe(afterTurn - Math.ceil(afterTurn * 0.05));
});
it('倒叩先原子消费旧业门,随后佛相完成效果再留下全新的业门', () => {
const { owner, enemy, skill } = install('mirror-karma');
addLayeredBuff(enemy, 'sect.wuxiang.mirror.karma-door', '旧业门', BuffType.DEBUFF, 2);
addLayeredBuff(owner, WUXIANG_KARMA_BUFF, '业痕', BuffType.BUFF, 1);
setAbilityMode(owner, {
key: WUXIANG_FORM_MODE,
mode: 'formless',
remainingUses: 1,
displayName: '无相',
});
const threeKnocks = skill('three-knocks');
const segments = damageSegments(threeKnocks, () =>
cast(threeKnocks, owner, enemy));
expect(segments).toEqual([0.28, 0.28, 0.28, 0.25, 0.25, 0.35]);
expect(enemy.buffs.getAllBuffs().find(
(buff) => buff.id === 'sect.wuxiang.mirror.karma-door',
)?.getLayer()).toBe(3);
});
it('无相观劫的两次直接减伤都生效,并各自触发一次魔相反击', () => {
const { owner, enemy, skill } = install('mirror-karma');
addLayeredBuff(owner, WUXIANG_KARMA_BUFF, '业痕', BuffType.BUFF, 1);
setAbilityMode(owner, {
key: WUXIANG_FORM_MODE,
mode: 'formless',
remainingUses: 1,
displayName: '无相',
});
cast(skill('observe-calamity'), owner, owner);
const counters: DamageRequestEvent[] = [];
EventBus.instance.subscribe<DamageRequestEvent>('DamageRequestEvent', (event) => {
if (event.damageSource === DamageSource.COUNTER) counters.push(event);
});
const reductions: number[] = [];
for (let hit = 0; hit < 2; hit += 1) {
const request: DamageRequestEvent = {
type: 'DamageRequestEvent',
timestamp: Date.now(),
caster: enemy,
target: owner,
damageSource: DamageSource.DIRECT,
damageType: DamageType.PHYSICAL,
baseDamage: 100,
finalDamage: 100,
};
EventBus.instance.publish(request);
reductions.push(request.damageReductionPctBucket ?? 0);
EventBus.instance.publish<DamageTakenEvent>({
type: 'DamageTakenEvent',
timestamp: Date.now(),
caster: enemy,
target: owner,
damageSource: DamageSource.DIRECT,
damageType: DamageType.PHYSICAL,
damageTaken: 65,
beforeHp: owner.getCurrentHp(),
remainHp: owner.getCurrentHp(),
isLethal: false,
});
}
expect(reductions).toEqual([0.35, 0.35]);
expect(counters).toHaveLength(2);
expect(counters.map((event) => event.damageComponents?.[0]?.segmentMultiplier))
.toEqual([0.45, 0.45]);
});
it('净化没有可选目标仍是合法结算,并消费一次魔相', () => {
const { owner, skill } = install('demon-crossing');
setAbilityMode(owner, {
key: WUXIANG_FORM_MODE,
mode: 'demon',
remainingUses: 2,
displayName: '魔相',
});
cast(skill('five-skandhas'), owner, owner);
expect(readAbilityMode(owner, WUXIANG_FORM_MODE)).toMatchObject({ remainingUses: 1 });
});
it('明镜仅在佛相响应敌方直接伤害,并按敌方行动首次受击留下业痕', () => {
const { owner, enemy } = install('mirror-karma');
const reflected: DamageRequestEvent[] = [];
EventBus.instance.subscribe<DamageRequestEvent>('DamageRequestEvent', (event) => {
if (event.damageSource === DamageSource.REFLECT) reflected.push(event);
});
const hit = (source: DamageSource) => EventBus.instance.publish<DamageTakenEvent>({
type: 'DamageTakenEvent', timestamp: Date.now(), caster: enemy, target: owner,
damageSource: source, damageType: DamageType.PHYSICAL,
damageTaken: owner.getMaxHp(), beforeHp: owner.getCurrentHp(),
remainHp: owner.getCurrentHp(), isLethal: false,
type: 'DamageTakenEvent',
timestamp: Date.now(),
caster: enemy,
target: owner,
damageSource: source,
damageType: DamageType.PHYSICAL,
damageTaken: 100,
beforeHp: owner.getCurrentHp(),
remainHp: owner.getCurrentHp(),
isLethal: false,
});
beginRuntimeAction(enemy);
hit(DamageSource.DIRECT);
hit(DamageSource.DIRECT);
hit(DamageSource.FOLLOW_UP);
expect(owner.buffs.getAllBuffs().find((buff) => buff.id === WUXIANG_KARMA_BUFF)?.getLayer()).toBe(1);
expect(owner.combatResources.getCurrent(WUXIANG_WAR_INTENT)).toBe(1);
expect(reflected.reduce((sum, event) => sum + event.finalDamage, 0)).toBe(Math.round(owner.getMaxHp() * 0.12));
expect(owner.buffs.getAllBuffs().find((buff) => buff.id === WUXIANG_KARMA_BUFF)?.getLayer())
.toBe(1);
expect(reflected).toHaveLength(2);
setAbilityMode(owner, {
key: WUXIANG_FORM_MODE,
mode: 'demon',
remainingUses: 2,
displayName: '魔相',
});
beginRuntimeAction(enemy);
hit(DamageSource.DIRECT);
expect(owner.buffs.getAllBuffs().find((buff) => buff.id === WUXIANG_KARMA_BUFF)?.getLayer())
.toBe(1);
expect(reflected).toHaveLength(2);
});
it('万业同门让无相明确尝试消费第二层业痕,不使用递归数值缩放', () => {
const { owner, enemy, defaultAttack } = install('mirror-karma', ['mirror-all-karma']);
const karma = BuffFactory.create({
id: WUXIANG_KARMA_BUFF,
name: '业痕',
type: BuffType.BUFF,
duration: -1,
stackRule: StackRule.STACK_LAYER,
maxLayers: 3,
});
owner.buffs.addBuff(karma, owner);
owner.buffs.addBuff(karma.clone(), owner);
setAbilityMode(owner, {
key: WUXIANG_FORM_MODE,
mode: 'formless',
remainingUses: 1,
displayName: '无相',
});
const segments: number[] = [];
EventBus.instance.subscribe<DamageRequestEvent>('DamageRequestEvent', (event) => {
if (event.ability !== defaultAttack) return;
const segment = event.damageComponents?.find((component) =>
component.segmentMultiplier !== undefined)?.segmentMultiplier;
if (segment !== undefined) segments.push(segment);
});
cast(defaultAttack, owner, enemy);
expect(segments).toEqual([0.6, 0.35, 0.3, 0.6]);
expect(owner.buffs.getAllBuffs().find((buff) => buff.id === WUXIANG_KARMA_BUFF)?.getLayer())
.toBe(1);
});
it('第二岸苦在魔相每门恢复一次,在无相 A+B+C 中也只恢复一次', () => {
const execute = (mode: 'demon' | 'formless') => {
const { owner, enemy, defaultAttack } = install('demon-crossing', ['demon-second-shore']);
owner.setHp(Math.floor(owner.getMaxHp() * 0.5));
setAbilityMode(owner, {
key: WUXIANG_FORM_MODE,
mode,
remainingUses: mode === 'demon' ? 2 : 1,
displayName: mode,
});
const before = owner.getCurrentHp();
cast(defaultAttack, owner, enemy);
const cost = Math.ceil(before * 0.06);
return owner.getCurrentHp() - (before - cost);
};
expect(execute('demon')).toBe(Math.round(unit('sample').getMaxHp() * 0.025));
expect(execute('formless')).toBe(Math.round(unit('sample').getMaxHp() * 0.025));
});
it('三叩低血强化读取支付气血成本后的施法快照', () => {
const executeAt = (hpRatio: number) => {
const { owner, enemy, skill } = install('demon-crossing');
owner.setHp(Math.floor(owner.getMaxHp() * hpRatio));
const knocks = skill('three-knocks');
return damageSegments(knocks, () => cast(knocks, owner, enemy));
};
expect(executeAt(0.50)).toEqual([0.25, 0.25, 0.25]);
expect(executeAt(0.48)).toEqual([0.25, 0.25, 0.25, 0.25]);
});
it('连续两次魔相使用同一 B,不存在第一门与第二门分支', () => {
const { owner, enemy, defaultAttack } = install('demon-crossing');
setAbilityMode(owner, {
key: WUXIANG_FORM_MODE,
mode: 'demon',
remainingUses: 2,
displayName: '魔相',
});
const first = damageSegments(defaultAttack, () => cast(defaultAttack, owner, enemy));
const second = damageSegments(defaultAttack, () => cast(defaultAttack, owner, enemy));
expect(first).toEqual([0.6, 0.35]);
expect(second).toEqual([0.6, 0.35]);
expect(readAbilityMode(owner, WUXIANG_FORM_MODE)).toBeUndefined();
});
it('渡厄吸血按宗门直接伤害标签触发,防御神通不会误触发吸血', () => {
const { owner, enemy, skill } = install('demon-crossing');
owner.setHp(Math.floor(owner.getMaxHp() * 0.4));
owner.combatResources.modify(WUXIANG_WAR_INTENT, 3);
cast(skill('turn-form'), owner, owner);
const flower = skill('flower-heart');
const beforeAttack = owner.getCurrentHp();
EventBus.instance.publish<DamageTakenEvent>({
type: 'DamageTakenEvent',
timestamp: Date.now(),
caster: owner,
target: enemy,
ability: flower,
damageSource: DamageSource.DIRECT,
damageType: DamageType.PHYSICAL,
damageTaken: 100,
beforeHp: enemy.getCurrentHp() + 100,
remainHp: enemy.getCurrentHp(),
isLethal: false,
});
expect(owner.getCurrentHp()).toBe(beforeAttack + 25);
const beforeDefense = owner.getCurrentHp();
cast(skill('blood-tide'), owner, owner);
expect(owner.getCurrentHp()).toBe(beforeDefense - Math.ceil(beforeDefense * 0.14));
});
it('低血节点只响应宗门神通气血成本跨线,且每场只触发一次', () => {
const { owner, enemy, defaultAttack } = install('demon-crossing', ['demon-three-shores']);
const maxHp = owner.getMaxHp();
owner.setHp(Math.floor(maxHp * 0.36));
cast(defaultAttack, owner, enemy);
expect(owner.getCurrentShield()).toBe(Math.round(maxHp * 0.08));
owner.setHp(Math.floor(maxHp * 0.36));
cast(defaultAttack, owner, enemy);
expect(owner.getCurrentShield()).toBe(Math.round(maxHp * 0.08));
});
it.each([
{
node: 'demon-body-breaks',
ratio: 0.31,
buffId: 'sect.wuxiang.demon.body-breaks-guard',
},
{
node: 'demon-blood-empty',
ratio: 0.26,
buffId: undefined,
},
])('$node 仅在气血成本首次跨线时触发一次', ({ node, ratio, buffId }) => {
const { owner, enemy, defaultAttack } = install('demon-crossing', [node]);
const maxHp = owner.getMaxHp();
owner.setHp(Math.floor(maxHp * ratio));
const before = owner.getCurrentHp();
cast(defaultAttack, owner, enemy);
const paid = Math.ceil(before * 0.06);
if (buffId) {
expect(owner.buffs.getAllBuffIds()).toContain(buffId);
} else {
expect(owner.getCurrentHp()).toBe(before - paid + Math.round(maxHp * 0.05));
}
owner.setHp(Math.floor(maxHp * ratio));
cast(defaultAttack, owner, enemy);
if (buffId) {
expect(owner.buffs.getAllBuffs().filter((buff) => buff.id === buffId)).toHaveLength(1);
} else {
expect(owner.getCurrentHp()).toBe(
Math.floor(maxHp * ratio) - Math.ceil(Math.floor(maxHp * ratio) * 0.06),
);
}
});
it('技能状态快照显示当前计划名称和稳定的实时气血成本', () => {
const { owner, skill } = install('mirror-karma');
owner.combatResources.modify(WUXIANG_WAR_INTENT, 6);
cast(skill('turn-form'), owner, owner);
const recorder = new BattleStateRecorder();
recorder.record('battle_init', 0, [owner]);
const defaultSkill = recorder.getFrames()[0].units[owner.id].cooldowns
.find((entry) => entry.skillId === 'sect.wuxiang.flower-heart');
expect(defaultSkill?.skillName).toBe('心花两忘');
expect(defaultSkill?.runtimePlanId).toBe('formless');
expect(defaultSkill?.costs?.[0]).toMatchObject({
resource: 'hp',
mode: 'current_hp_ratio',
ratio: 0.05,
resolvedAmount: Math.ceil(owner.getCurrentHp() * 0.05),
});
});
});
@@ -28,7 +28,9 @@ export interface ActiveSectAbilitySpec {
role?: SectAbilityRole;
mpCost?: number;
costs?: AbilityConfig['costs'];
variants?: AbilityConfig['variants'];
completionEffects?: AbilityConfig['completionEffects'];
effectLayers?: AbilityConfig['effectLayers'];
effectPlans?: AbilityConfig['effectPlans'];
cooldown?: number;
effects: EffectConfig[];
castEffects?: EffectConfig[];
@@ -51,8 +53,9 @@ export class SectAbilityFactory {
const targetPolicy = spec.targetPolicy;
const capabilities = analyzeAbilityCapabilities({
effects: spec.effects,
completionEffects: spec.completionEffects,
effectLayers: spec.effectLayers,
castEffects: spec.castEffects,
variants: spec.variants,
slug: spec.definition.id,
});
const selectionProfile =
@@ -103,8 +106,10 @@ export class SectAbilityFactory {
targetPolicy,
selectionProfile,
castConditions: spec.castConditions,
variants: spec.variants,
effects: spec.effects,
completionEffects: spec.completionEffects,
effectLayers: spec.effectLayers,
effectPlans: spec.effectPlans,
castEffects: spec.castEffects,
};
return {
@@ -124,21 +124,24 @@ export class StandardSectMethodGrowthPolicy implements SectMethodGrowthPolicy {
projected.config.effects = projected.config.effects?.map((effect) =>
this.projectEffect(effect, growth, methodLevels),
);
projected.config.completionEffects = projected.config.completionEffects?.map((effect) =>
this.projectEffect(effect, growth, methodLevels),
);
projected.config.effectLayers = projected.config.effectLayers?.map((layer) => ({
...layer,
effects: layer.effects?.map((effect) =>
this.projectEffect(effect, growth, methodLevels),
),
completionEffects: layer.completionEffects?.map((effect) =>
this.projectEffect(effect, growth, methodLevels),
),
}));
projected.config.castEffects = projected.config.castEffects?.map((effect) =>
this.projectEffect(effect, growth, methodLevels),
);
projected.config.listeners = projected.config.listeners?.map((listener) =>
this.projectListener(listener, growth, methodLevels),
);
projected.config.variants = projected.config.variants?.map((variant) => ({
...variant,
effects: variant.effects?.map((effect) =>
this.projectEffect(effect, growth, methodLevels),
),
castEffects: variant.castEffects?.map((effect) =>
this.projectEffect(effect, growth, methodLevels),
),
}));
return projected;
}
@@ -182,6 +185,18 @@ export class StandardSectMethodGrowthPolicy implements SectMethodGrowthPolicy {
projected.config.effects = projected.config.effects?.map((effect) =>
this.projectEffect(effect, fixedGrowth, methodLevels),
);
projected.config.completionEffects = projected.config.completionEffects?.map((effect) =>
this.projectEffect(effect, fixedGrowth, methodLevels),
);
projected.config.effectLayers = projected.config.effectLayers?.map((layer) => ({
...layer,
effects: layer.effects?.map((effect) =>
this.projectEffect(effect, fixedGrowth, methodLevels),
),
completionEffects: layer.completionEffects?.map((effect) =>
this.projectEffect(effect, fixedGrowth, methodLevels),
),
}));
projected.config.castEffects = projected.config.castEffects?.map((effect) =>
this.projectEffect(effect, fixedGrowth, methodLevels),
);
@@ -271,9 +286,7 @@ export class StandardSectMethodGrowthPolicy implements SectMethodGrowthPolicy {
case 'skip_action':
case 'queue_action':
case 'ability_mode':
case 'status_transfer':
case 'lifesteal':
case 'damage_cap':
break;
default:
assertNeverEffect(projected);
@@ -281,6 +294,7 @@ export class StandardSectMethodGrowthPolicy implements SectMethodGrowthPolicy {
const params = projected.params as {
effects?: EffectConfig[];
fallbackEffects?: EffectConfig[];
cancelEffects?: EffectConfig[];
};
if (params.effects) {
@@ -288,6 +302,11 @@ export class StandardSectMethodGrowthPolicy implements SectMethodGrowthPolicy {
this.projectEffect(nested, growth, methodLevels),
);
}
if (params.fallbackEffects) {
params.fallbackEffects = params.fallbackEffects.map((nested) =>
this.projectEffect(nested, growth, methodLevels),
);
}
if (params.cancelEffects) {
params.cancelEffects = params.cancelEffects.map((nested) =>
this.projectEffect(nested, growth, methodLevels),
@@ -252,7 +252,7 @@ function listenerChildText(
? `向目标施加1层${effect.params.buffConfig.name}${effect.params.buffConfig.duration >= 0 ? `,持续目标未来${effect.params.buffConfig.duration}次行动` : ''}`
: `获得《${effect.params.buffConfig.name}》`;
case 'dispel':
return `驱散目标${effect.params.maxCount ?? 1}个正面状态`;
return dispelActionText(effect.params);
case 'percent_damage_modifier':
return effect.params.mode === 'reduce'
? `受到的直接伤害降低${percent(effect.params.value)}`
@@ -262,6 +262,30 @@ function listenerChildText(
}
}
function dispelActionText(
params: Extract<EffectConfig, { type: 'dispel' }>['params'],
): string {
const subject = params.recipient === 'caster' ? '自身' : '目标';
const count = params.maxCount ?? 1;
if (params.status === 'negative') {
return `净化${subject}${count}个负面状态`;
}
if (params.status === 'positive' || (!params.status && params.recipient !== 'caster')) {
return `驱散${subject}${count}个正面状态`;
}
return `移除${subject}${count}个匹配状态`;
}
function nestedEffectRows(
effects: readonly EffectConfig[],
resources: readonly CombatResourceDefinition[],
): string[] {
return [
...damageEffectsRow(effects, resources),
...effects.flatMap((effect) => describeEffect(effect, resources)),
];
}
function listenerRows(
listener: ListenerConfig,
resources: readonly CombatResourceDefinition[],
@@ -407,8 +431,16 @@ function describeEffect(
}
return [describeEffectCore(effect)];
}
case 'dispel':
return [`驱散:目标${effect.params.maxCount ?? 1}个正面状态`];
case 'dispel': {
const action = dispelActionText(effect.params);
return [
`${effect.params.status === 'negative' ? '净化' : effect.params.status === 'positive' || effect.params.recipient !== 'caster' ? '驱散' : '移除'}:${action.replace(/^(净化|驱散|移除)/, '')}`,
...nestedEffectRows(effect.params.effects ?? [], resources)
.map((row) => `成功后:${row}`),
...nestedEffectRows(effect.params.fallbackEffects ?? [], resources)
.map((row) => `无可移除状态时:${row}`),
];
}
case 'ability_transform':
return [describeEffectCore(effect)];
case 'consume_status_trigger': {
@@ -515,16 +547,17 @@ export function describeSectAbilityConfig(
resources: readonly CombatResourceDefinition[],
): string[] {
const rows: string[] = [];
const variantRows = config.variants?.map((variant) => {
const cost = variant.costs?.find((entry) => entry.resource === 'hp');
const costText = cost && cost.mode !== 'flat'
? `${number(cost.ratio * 100)}%当前气血`
: cost?.mode === 'flat'
? `${cost.amount}气血`
: '沿用基础消耗';
return `形态「${variant.name}」:${costText}`;
}) ?? [];
rows.push(...variantRows);
const layers = config.effectLayers ?? [];
const isLayered = layers.length > 0;
const formName = (layerId: string): string =>
layerId === 'demon'
? '魔相'
: layerId === 'formless'
? '无相'
: layerId;
rows.push(...(config.effectPlans?.map((plan) =>
`效果计划「${plan.name}」:${['佛相', ...plan.layerIds.map(formName)].join(' + ')}`,
) ?? []));
for (const condition of config.castConditions ?? []) {
if (
condition.type === 'combat_resource_at_least' &&
@@ -536,18 +569,36 @@ export function describeSectAbilityConfig(
);
}
}
const allEffects = [
...(config.effects ?? []),
...(config.variants?.flatMap((variant) => variant.effects ?? []) ?? []),
const baseTiming = config.targetPolicy?.team === 'enemy' ? 'hit' : 'cast';
const baseEffectRows = [
...damageEffectsRow(config.effects ?? [], resources),
...describeEffectList(config.effects ?? [], resources, baseTiming),
];
rows.push(...damageEffectsRow(allEffects, resources));
rows.push(
...describeEffectList(
config.effects ?? [],
resources,
config.targetPolicy?.team === 'enemy' ? 'hit' : 'cast',
),
);
rows.push(...(isLayered
? baseEffectRows.map((row) => `佛相主体:${row}`)
: baseEffectRows));
const baseCompletionRows = [
...damageEffectsRow(config.completionEffects ?? [], resources),
...describeEffectList(config.completionEffects ?? [], resources, 'cast'),
];
rows.push(...(isLayered
? baseCompletionRows.map((row) => `佛相完成:${row}`)
: baseCompletionRows));
for (const layer of layers) {
const label = layer.id === 'demon'
? '魔相追加'
: layer.id === 'formless'
? '无相追加'
: `追加层 ${layer.id}`;
rows.push(...[
...damageEffectsRow(layer.effects ?? [], resources),
...describeEffectList(layer.effects ?? [], resources, baseTiming),
].map((row) => `${label}:${row}`));
rows.push(...[
...damageEffectsRow(layer.completionEffects ?? [], resources),
...describeEffectList(layer.completionEffects ?? [], resources, 'cast'),
].map((row) => `${label}完成:${row}`));
}
rows.push(...describeEffectList(config.castEffects ?? [], resources, 'cast'));
return Array.from(new Set(rows.filter(Boolean)));
}