重构:机制简化
This commit is contained in:
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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
@@ -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)));
|
||||
}
|
||||
Reference in new issue
Block a user