Merge pull request #57 from ChurchTao/codex/worktreebug
Add round-based buff durations and periodic effect timing
This commit is contained in:
21 files changed
+276
-52
No files matched your search
@@ -45,6 +45,7 @@ export class Buff {
|
||||
readonly dispelMode: 'whole' | 'one_layer';
|
||||
readonly countsAsStatus: boolean;
|
||||
readonly removeOnDeath: boolean;
|
||||
readonly durationUnit: 'owner_action' | 'round';
|
||||
private _duration: number;
|
||||
private _maxDuration: number;
|
||||
|
||||
@@ -85,6 +86,7 @@ export class Buff {
|
||||
stackPriority: number = 0,
|
||||
dispelMode: 'whole' | 'one_layer' = 'whole',
|
||||
removeOnDeath: boolean = false,
|
||||
durationUnit: 'owner_action' | 'round' = 'owner_action',
|
||||
) {
|
||||
this.id = id;
|
||||
this.name = name;
|
||||
@@ -97,6 +99,7 @@ export class Buff {
|
||||
this.dispelMode = dispelMode;
|
||||
this.countsAsStatus = countsAsStatus;
|
||||
this.removeOnDeath = removeOnDeath;
|
||||
this.durationUnit = durationUnit;
|
||||
this.type = type;
|
||||
this._maxDuration = duration;
|
||||
this._duration = duration;
|
||||
@@ -337,6 +340,7 @@ export class Buff {
|
||||
this.stackPriority,
|
||||
this.dispelMode,
|
||||
this.removeOnDeath,
|
||||
this.durationUnit,
|
||||
);
|
||||
cloned.setDuration(this._duration);
|
||||
cloned.tags = this.tags.clone();
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
import {
|
||||
claimGlobalUniqueEffect,
|
||||
releaseGlobalUniqueEffects,
|
||||
shouldTickBuffDuration,
|
||||
} from '../core/runtimeState';
|
||||
import { BuffId, CombatEvent } from '../core/types';
|
||||
import {
|
||||
@@ -47,6 +48,7 @@ export class DataDrivenBuff extends Buff {
|
||||
config.stackPriority,
|
||||
config.dispelMode,
|
||||
config.removeOnDeath,
|
||||
config.durationUnit,
|
||||
);
|
||||
this._config = config;
|
||||
}
|
||||
@@ -151,6 +153,15 @@ export class DataDrivenBuff extends Buff {
|
||||
): void {
|
||||
if (!this._owner) return; // 仅检查是否存在,不检查存活,以便处理免死逻辑
|
||||
|
||||
// 回合型 Buff 在施加当回合不立即跳一次,避免“施加即结算”导致额外触发。
|
||||
if (
|
||||
event.type === 'RoundPostEvent' &&
|
||||
this.durationUnit === 'round' &&
|
||||
!shouldTickBuffDuration(this._owner, this)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!shouldExecuteListener(this._owner, event, runtime, this)) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -754,6 +754,8 @@ export interface BuffConfig {
|
||||
description?: string;
|
||||
type: BuffType;
|
||||
duration: number; // -1 为永久
|
||||
/** 持续时间递减单位;默认随宿主行动,周期状态可按回合递减。 */
|
||||
durationUnit?: 'owner_action' | 'round';
|
||||
/** 内部计数/防重复 marker 可仅保留在调试日志。 */
|
||||
logVisibility?: 'player' | 'debug';
|
||||
/** 状态栏可见性;缺省时沿用日志可见性以保持兼容。 */
|
||||
|
||||
@@ -49,7 +49,9 @@ export enum EventPriorityLevel {
|
||||
DAMAGE_REQUEST = 60, // 伤害请求(增伤修正)
|
||||
DAMAGE_APPLY = 55, // 伤害应用(护盾/无敌响应)
|
||||
DAMAGE_TAKEN = 50, // 受击事件(触发被动/反伤)
|
||||
ROUND_POST_RECOVERY = 46, // 回合结束周期恢复
|
||||
ROUND_PRE = 45, // 回合前置结算(DOT、BUFF结算等)
|
||||
ROUND_POST_DRAIN = 44, // 回合结束周期损耗(DOT、资源扣除)
|
||||
BUFF_INTERCEPT = 40, // BUFF 拦截(高于 POST_SETTLE)
|
||||
TAG_CHANGE = 35, // 标签变更
|
||||
POST_SETTLE = 30, // 后置结算
|
||||
|
||||
@@ -111,7 +111,7 @@ describe('affixText mechanic rendering', () => {
|
||||
expect(view.mechanicNotes.join('、')).toContain('DOT');
|
||||
expect(view.bodyText).toContain('60%概率附加「灼烧」');
|
||||
expect(view.bodyText).toContain('3回合');
|
||||
expect(view.bodyText).toContain('行动前造成');
|
||||
expect(view.bodyText).toContain('触发时造成');
|
||||
expect(view.bodyText).toContain('持续伤害(DOT)');
|
||||
expect(view.bodyText).toContain('按层数放大');
|
||||
});
|
||||
|
||||
@@ -1,12 +1,15 @@
|
||||
import { GameplayTags } from '@shared/engine/shared/tag-domain';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { BattleRoster } from '../core/BattleRoster';
|
||||
import type { AbilityConfig } from '../core/configs';
|
||||
import type { AbilityConfig, BuffConfig } from '../core/configs';
|
||||
import {
|
||||
AbilityType,
|
||||
AttributeType,
|
||||
BuffType,
|
||||
DamageType,
|
||||
} from '../core/types';
|
||||
import { StackRule } from '../buffs/Buff';
|
||||
import { EventPriorityLevel } from '../core/events';
|
||||
import { AbilityFactory } from '../factories/AbilityFactory';
|
||||
import { BuffFactory } from '../factories/BuffFactory';
|
||||
import {
|
||||
@@ -52,7 +55,7 @@ const aoeAbility: AbilityConfig = {
|
||||
],
|
||||
};
|
||||
|
||||
function initialSave(): BattleSaveV1 {
|
||||
function initialSave(configure?: (units: Unit[]) => void): BattleSaveV1 {
|
||||
const runtime = new BattleRuntime();
|
||||
const units = [
|
||||
new Unit(
|
||||
@@ -66,6 +69,7 @@ function initialSave(): BattleSaveV1 {
|
||||
new Unit('b1', 'b1', {}, { runtime, teamId: 'beta', slot: 1 }),
|
||||
];
|
||||
units[0].abilities.addAbility(AbilityFactory.create(aoeAbility));
|
||||
configure?.(units);
|
||||
const roster = new BattleRoster(units);
|
||||
const blueprint = createBattleBlueprint('team-round', roster);
|
||||
return {
|
||||
@@ -101,6 +105,124 @@ function commands(): RoundCommandSetV1 {
|
||||
}
|
||||
|
||||
describe('BattleRoundResolver', () => {
|
||||
it('settles round recovery before periodic damage at round post', () => {
|
||||
const save = initialSave((units) => {
|
||||
const unit = units[0];
|
||||
unit.setHp(unit.getMaxHp() - 10, 'set');
|
||||
unit.abilities.addAbility(
|
||||
AbilityFactory.create({
|
||||
slug: 'round-recovery',
|
||||
name: '回合恢复',
|
||||
type: AbilityType.PASSIVE_SKILL,
|
||||
tags: [
|
||||
GameplayTags.ABILITY.FUNCTION.BUFF,
|
||||
GameplayTags.ABILITY.FUNCTION.DAMAGE,
|
||||
GameplayTags.ABILITY.FUNCTION.HEAL,
|
||||
GameplayTags.ABILITY.CHANNEL.TRUE,
|
||||
],
|
||||
listeners: [
|
||||
{
|
||||
eventType: GameplayTags.EVENT.ROUND_POST,
|
||||
scope: 'global',
|
||||
priority: EventPriorityLevel.ROUND_POST_RECOVERY,
|
||||
effects: [
|
||||
{
|
||||
type: 'heal',
|
||||
params: { value: { base: 20, coefficient: 0 } },
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
eventType: GameplayTags.EVENT.ROUND_POST,
|
||||
scope: 'global',
|
||||
priority: EventPriorityLevel.ROUND_POST_DRAIN,
|
||||
effects: [
|
||||
{
|
||||
type: 'damage',
|
||||
params: {
|
||||
value: { base: 15, coefficient: 0 },
|
||||
damageType: DamageType.TRUE,
|
||||
canCrit: false,
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
}),
|
||||
);
|
||||
});
|
||||
const result = resolveBattleRound(save, {
|
||||
version: 'round_command_set_v1',
|
||||
commandSetId: 'round-post-order',
|
||||
round: 1,
|
||||
checkpointRevision: 0,
|
||||
intents: {
|
||||
a0: { kind: 'skip', submittedBy: 'timeout' },
|
||||
a1: { kind: 'skip', submittedBy: 'timeout' },
|
||||
b0: { kind: 'skip', submittedBy: 'timeout' },
|
||||
b1: { kind: 'skip', submittedBy: 'timeout' },
|
||||
},
|
||||
});
|
||||
|
||||
const restoredA0 = restoreBattleSave(result.save).roster.getUnit('a0');
|
||||
expect(restoredA0.getCurrentHp()).toBeLessThan(restoredA0.getMaxHp());
|
||||
expect(restoredA0.getCurrentHp()).toBeGreaterThan(restoredA0.getMaxHp() - 30);
|
||||
expect(result.stateTimeline.frames.some((frame) => frame.phase === 'round_post')).toBe(true);
|
||||
});
|
||||
|
||||
it('ticks round-duration DOT buffs after the round post event', () => {
|
||||
const save = initialSave((units) => {
|
||||
const dot: BuffConfig = {
|
||||
id: 'round-dot-test',
|
||||
name: '回合毒伤',
|
||||
type: BuffType.DEBUFF,
|
||||
duration: 2,
|
||||
durationUnit: 'round',
|
||||
stackRule: StackRule.REFRESH_DURATION,
|
||||
listeners: [
|
||||
{
|
||||
eventType: GameplayTags.EVENT.ROUND_POST,
|
||||
scope: 'global',
|
||||
priority: EventPriorityLevel.ROUND_POST_DRAIN,
|
||||
effects: [
|
||||
{
|
||||
type: 'damage',
|
||||
params: {
|
||||
value: { base: 5, coefficient: 0 },
|
||||
damageType: DamageType.TRUE,
|
||||
canCrit: false,
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
units[0].buffs.addBuff(BuffFactory.create(dot), units[0]);
|
||||
});
|
||||
|
||||
const result = resolveBattleRound(save, {
|
||||
version: 'round_command_set_v1',
|
||||
commandSetId: 'round-duration-dot',
|
||||
round: 1,
|
||||
checkpointRevision: 0,
|
||||
intents: {
|
||||
a0: { kind: 'skip', submittedBy: 'timeout' },
|
||||
a1: { kind: 'skip', submittedBy: 'timeout' },
|
||||
b0: { kind: 'skip', submittedBy: 'timeout' },
|
||||
b1: { kind: 'skip', submittedBy: 'timeout' },
|
||||
},
|
||||
});
|
||||
|
||||
const restoredA0 = restoreBattleSave(result.save).roster.getUnit('a0');
|
||||
expect(restoredA0.getCurrentHp()).toBeLessThan(restoredA0.getMaxHp());
|
||||
expect(
|
||||
restoredA0.buffs
|
||||
.getAllBuffs()
|
||||
.find((buff) => buff.id === 'round-dot-test')
|
||||
?.getDuration(),
|
||||
).toBe(1);
|
||||
});
|
||||
|
||||
it('resolves one sealed 2v2 round atomically and charges AOE once', () => {
|
||||
const save = initialSave();
|
||||
const commandSet = sealRoundCommandSet(save, commands());
|
||||
|
||||
@@ -229,7 +229,7 @@ function resolveRestoredBattleRound(
|
||||
controlledSkip,
|
||||
actor.getCurrentShield() > 0,
|
||||
);
|
||||
processBuffDurations(actor);
|
||||
processBuffDurations(actor, 'owner_action');
|
||||
actor.abilities.tickAbilitiesCooldown();
|
||||
}
|
||||
stateRecorder.record(
|
||||
@@ -244,12 +244,16 @@ function resolveRestoredBattleRound(
|
||||
}
|
||||
|
||||
let outcome!: ReturnType<typeof TeamVictorySystem.check>;
|
||||
resolutionContext.runFrame({ phase: 'round_post', turn: round }, () => {
|
||||
resolutionContext.runFrame({ phase: 'round_post', turn: round }, (sequence) => {
|
||||
eventBus.publish<RoundPostEvent>({
|
||||
type: 'RoundPostEvent',
|
||||
timestamp: runtime.clock.now(),
|
||||
turn: round,
|
||||
});
|
||||
for (const unit of allUnits) {
|
||||
processBuffDurations(unit, 'round');
|
||||
}
|
||||
stateRecorder.record('round_post', round, allUnits, undefined, sequence.id);
|
||||
outcome = TeamVictorySystem.check(roster, runtime.random, round);
|
||||
eventBus.publish<VictoryCheckEvent>({
|
||||
type: 'VictoryCheckEvent',
|
||||
@@ -660,9 +664,13 @@ function validateRoundCommandSet(
|
||||
}
|
||||
}
|
||||
|
||||
function processBuffDurations(unit: Unit): void {
|
||||
function processBuffDurations(
|
||||
unit: Unit,
|
||||
durationUnit: 'owner_action' | 'round',
|
||||
): void {
|
||||
for (const buff of unit.buffs.getAllBuffs()) {
|
||||
if (!unit.isAlive()) break;
|
||||
if (buff.durationUnit !== durationUnit) continue;
|
||||
if (!shouldTickBuffDuration(unit, buff)) continue;
|
||||
buff.tickDuration();
|
||||
if (buff.isExpired()) {
|
||||
|
||||
@@ -26,8 +26,9 @@ import {
|
||||
* 采样时机(由单回合解析器与自动战斗外壳触发):
|
||||
* 1. battle_init — 战斗开始后(基线快照)
|
||||
* 2. action_pre — 每个单位的 ActionPreEvent 发布并处理完毕后
|
||||
* 3. action_post — 该单位的动作执行、Buff 过期、CD 刷新全部完成后
|
||||
* 4. battle_end — 战斗结束后(终态快照)
|
||||
* 3. action_post — 该单位的动作执行、行动型 Buff 过期、CD 刷新完成后
|
||||
* 4. round_post — 回合结束周期结算与回合型 Buff 过期完成后
|
||||
* 5. battle_end — 战斗结束后(终态快照)
|
||||
*
|
||||
* 设计原则:
|
||||
* - 与日志系统完全解耦,不依赖 EventBus
|
||||
@@ -191,7 +192,7 @@ export class BattleStateRecorder {
|
||||
sourceName: buff.getSource()?.name,
|
||||
layers: buff.getLayer(),
|
||||
remaining: buff.isPermanent() ? -1 : buff.getDuration(),
|
||||
durationUnit: 'owner_action',
|
||||
durationUnit: buff.durationUnit,
|
||||
isPermanent: buff.isPermanent(),
|
||||
}));
|
||||
}
|
||||
|
||||
@@ -153,7 +153,7 @@ export interface UnitStateDelta {
|
||||
|
||||
// ===== 状态帧类型 =====
|
||||
export type StateFramePhase =
|
||||
'battle_init' | 'action_pre' | 'action_post' | 'battle_end';
|
||||
'battle_init' | 'action_pre' | 'action_post' | 'round_post' | 'battle_end';
|
||||
|
||||
// ===== 战斗状态帧 =====
|
||||
export interface BattleStateFrame {
|
||||
|
||||
@@ -211,7 +211,12 @@ export type CombatFactDraftV3 = CombatFactV3 extends infer T
|
||||
export interface BattleStateFrameV3 {
|
||||
frameId: number;
|
||||
turn: number;
|
||||
phase: 'battle_init' | 'action_pre' | 'action_post' | 'battle_end';
|
||||
phase:
|
||||
| 'battle_init'
|
||||
| 'action_pre'
|
||||
| 'action_post'
|
||||
| 'round_post'
|
||||
| 'battle_end';
|
||||
actorId?: string;
|
||||
sourceSequenceId: string;
|
||||
units: Record<string, UnitStateSnapshot>;
|
||||
|
||||
@@ -784,9 +784,9 @@ export const ARTIFACT_AFFIXES: AffixDefinition[] = [
|
||||
},
|
||||
},
|
||||
listenerSpec: {
|
||||
eventType: GameplayTags.EVENT.ROUND_PRE,
|
||||
eventType: GameplayTags.EVENT.ROUND_POST,
|
||||
scope: GameplayTags.SCOPE.GLOBAL,
|
||||
priority: CREATION_LISTENER_PRIORITIES.roundPre,
|
||||
priority: CREATION_LISTENER_PRIORITIES.roundPostRecovery,
|
||||
},
|
||||
},
|
||||
|
||||
@@ -1029,7 +1029,7 @@ export const ARTIFACT_AFFIXES: AffixDefinition[] = [
|
||||
{
|
||||
id: 'artifact-defense-magic-shield',
|
||||
displayName: '法力护盾',
|
||||
displayDescription: '受击时优先以灵力抵挡部分伤害',
|
||||
displayDescription: '当前法力高于 30% 时,受击时优先以灵力抵挡部分伤害',
|
||||
slot: 'modifier',
|
||||
rarity: 'rare',
|
||||
match: {
|
||||
@@ -1046,6 +1046,12 @@ export const ARTIFACT_AFFIXES: AffixDefinition[] = [
|
||||
applicableArtifactSlots: ['accessory'],
|
||||
effectTemplate: {
|
||||
type: 'magic_shield',
|
||||
conditions: [
|
||||
{
|
||||
type: 'mp_above',
|
||||
params: { value: 0.3, scope: 'target' },
|
||||
},
|
||||
],
|
||||
params: {
|
||||
absorbRatio: { base: 0.18, scale: 'quality', coefficient: 0.03 },
|
||||
},
|
||||
@@ -1701,9 +1707,9 @@ export const ARTIFACT_AFFIXES: AffixDefinition[] = [
|
||||
},
|
||||
},
|
||||
listenerSpec: {
|
||||
eventType: GameplayTags.EVENT.ROUND_PRE,
|
||||
eventType: GameplayTags.EVENT.ROUND_POST,
|
||||
scope: GameplayTags.SCOPE.GLOBAL,
|
||||
priority: CREATION_LISTENER_PRIORITIES.roundPre,
|
||||
priority: CREATION_LISTENER_PRIORITIES.roundPostRecovery,
|
||||
},
|
||||
},
|
||||
{
|
||||
|
||||
@@ -27,8 +27,8 @@ import {
|
||||
} from './utils';
|
||||
|
||||
const DOT_TICK_LISTENER = {
|
||||
eventType: GameplayTags.EVENT.ACTION_PRE,
|
||||
scope: GameplayTags.SCOPE.OWNER_AS_ACTOR,
|
||||
eventType: GameplayTags.EVENT.ROUND_POST,
|
||||
scope: GameplayTags.SCOPE.GLOBAL,
|
||||
priority: CREATION_LISTENER_PRIORITIES.dotTick,
|
||||
} as const;
|
||||
|
||||
@@ -1043,6 +1043,7 @@ export const SKILL_AFFIXES: AffixDefinition[] = [
|
||||
name: '灼烧',
|
||||
type: BuffType.DEBUFF,
|
||||
duration: CREATION_DURATION_POLICY.buffDebuff.short,
|
||||
durationUnit: 'round',
|
||||
stackRule: StackRule.STACK_LAYER,
|
||||
tags: [
|
||||
GameplayTags.BUFF.TYPE.DEBUFF,
|
||||
@@ -1144,6 +1145,7 @@ export const SKILL_AFFIXES: AffixDefinition[] = [
|
||||
name: '中毒',
|
||||
type: BuffType.DEBUFF,
|
||||
duration: CREATION_DURATION_POLICY.buffDebuff.standard,
|
||||
durationUnit: 'round',
|
||||
stackRule: StackRule.STACK_LAYER,
|
||||
tags: [
|
||||
GameplayTags.BUFF.TYPE.DEBUFF,
|
||||
@@ -1203,6 +1205,7 @@ export const SKILL_AFFIXES: AffixDefinition[] = [
|
||||
name: '流血',
|
||||
type: BuffType.DEBUFF,
|
||||
duration: CREATION_DURATION_POLICY.buffDebuff.short,
|
||||
durationUnit: 'round',
|
||||
stackRule: StackRule.STACK_LAYER,
|
||||
tags: [
|
||||
GameplayTags.BUFF.TYPE.DEBUFF,
|
||||
|
||||
@@ -211,8 +211,8 @@ export const CREATION_AFFIX_SLOT_PLAN = {
|
||||
export const CREATION_LISTENER_PRIORITIES = {
|
||||
// 行动触发前的增益或预处理优先级。
|
||||
actionPreBuff: CREATION_EVENT_PRIORITY_LEVELS.ACTION_TRIGGER,
|
||||
// DOT 在行动前触发,但应晚于同帧预处理增益。
|
||||
dotTick: CREATION_EVENT_PRIORITY_LEVELS.ROUND_PRE,
|
||||
// DOT 在回合结束损耗阶段触发。
|
||||
dotTick: CREATION_EVENT_PRIORITY_LEVELS.ROUND_POST_DRAIN,
|
||||
// 技能施放瞬间的监听优先级。
|
||||
skillCast: CREATION_EVENT_PRIORITY_LEVELS.SKILL_CAST,
|
||||
// 伤害请求阶段的监听优先级,必须早于 DamageSystem 本身,
|
||||
@@ -226,6 +226,10 @@ export const CREATION_LISTENER_PRIORITIES = {
|
||||
damageTaken: CREATION_EVENT_PRIORITY_LEVELS.DAMAGE_TAKEN,
|
||||
// 回合开始前的监听优先级。
|
||||
roundPre: CREATION_EVENT_PRIORITY_LEVELS.ROUND_PRE,
|
||||
// 回合结束时的周期恢复优先级。
|
||||
roundPostRecovery: CREATION_EVENT_PRIORITY_LEVELS.ROUND_POST_RECOVERY,
|
||||
// 回合结束时的周期损耗优先级。
|
||||
roundPostDrain: CREATION_EVENT_PRIORITY_LEVELS.ROUND_POST_DRAIN,
|
||||
// Buff 拦截阶段的监听优先级。
|
||||
buffIntercept: CREATION_EVENT_PRIORITY_LEVELS.BUFF_INTERCEPT,
|
||||
} as const;
|
||||
|
||||
@@ -6,9 +6,11 @@ export const CREATION_EVENT_PRIORITY_LEVELS = {
|
||||
DAMAGE_REQUEST: 60,
|
||||
DAMAGE_APPLY: 55,
|
||||
DAMAGE_TAKEN: 50,
|
||||
ROUND_POST_RECOVERY: 46,
|
||||
ROUND_PRE: 45,
|
||||
ROUND_POST_DRAIN: 44,
|
||||
BUFF_INTERCEPT: 40,
|
||||
TAG_CHANGE: 35,
|
||||
POST_SETTLE: 30,
|
||||
COMBAT_LOG: 10,
|
||||
} as const;
|
||||
} as const;
|
||||
@@ -1,5 +1,6 @@
|
||||
import { ARTIFACT_AFFIXES } from '@shared/engine/creation-v2/affixes/definitions/artifactAffixes';
|
||||
import { GONGFA_AFFIXES } from '@shared/engine/creation-v2/affixes/definitions/gongfaAffixes';
|
||||
import { SKILL_AFFIXES } from '@shared/engine/creation-v2/affixes/definitions/skillAffixes';
|
||||
import type { AffixDefinition } from '@shared/engine/creation-v2/affixes/types';
|
||||
import { AttributeType } from '@shared/engine/creation-v2/contracts/battle';
|
||||
import { CreationTags } from '@shared/engine/shared/tag-domain';
|
||||
@@ -13,6 +14,47 @@ function getAffix(affixes: AffixDefinition[], id: string): AffixDefinition {
|
||||
}
|
||||
|
||||
describe('resource affix balance contracts', () => {
|
||||
it.each([
|
||||
['skill-variant-burn-dot', 'craft-burn'],
|
||||
['skill-variant-poison-dot', 'craft-poison'],
|
||||
['skill-variant-bleed-dot', 'craft-bleed'],
|
||||
])('%s uses round duration for its RoundPost DOT buff', (affixId, buffId) => {
|
||||
const affix = getAffix(SKILL_AFFIXES, affixId);
|
||||
expect(affix.effectTemplate.type).toBe('apply_buff');
|
||||
if (affix.effectTemplate.type !== 'apply_buff') return;
|
||||
|
||||
expect(affix.effectTemplate.params.buffConfig).toMatchObject({
|
||||
id: buffId,
|
||||
durationUnit: 'round',
|
||||
listeners: [
|
||||
expect.objectContaining({ eventType: 'RoundPostEvent' }),
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it.each([
|
||||
'skill-core-wind-haste',
|
||||
'skill-core-ice-frost-guard',
|
||||
'skill-core-metal-honed-edge',
|
||||
])('%s keeps owner-action duration semantics', (affixId) => {
|
||||
const affix = getAffix(SKILL_AFFIXES, affixId);
|
||||
expect(affix.effectTemplate.type).toBe('apply_buff');
|
||||
if (affix.effectTemplate.type !== 'apply_buff') return;
|
||||
|
||||
expect(affix.effectTemplate.params.buffConfig.durationUnit).toBeUndefined();
|
||||
});
|
||||
|
||||
it('only enables the unconditional magic shield while current mana is above 30%', () => {
|
||||
const affix = getAffix(ARTIFACT_AFFIXES, 'artifact-defense-magic-shield');
|
||||
|
||||
expect(affix.effectTemplate).toMatchObject({
|
||||
type: 'magic_shield',
|
||||
conditions: [
|
||||
{ type: 'mp_above', params: { value: 0.3, scope: 'target' } },
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it('keeps spirit-breaking awl simple while reducing both mana-burn scalars', () => {
|
||||
const affix = getAffix(
|
||||
ARTIFACT_AFFIXES,
|
||||
|
||||
@@ -278,7 +278,8 @@ function periodicDamageBuff(
|
||||
},
|
||||
};
|
||||
return buff(id, name, BuffType.DEBUFF, 2, {
|
||||
description: `${name}每次行动前造成持续法术伤害。`,
|
||||
description: `${name}每回合结束造成持续法术伤害。`,
|
||||
durationUnit: 'round',
|
||||
tags: [
|
||||
GameplayTags.BUFF.TYPE.DEBUFF,
|
||||
GameplayTags.BUFF.DOT.ROOT,
|
||||
@@ -298,9 +299,9 @@ function periodicDamageBuff(
|
||||
listeners: [
|
||||
{
|
||||
id: `${id}.periodic`,
|
||||
eventType: GameplayTags.EVENT.ACTION_PRE,
|
||||
scope: GameplayTags.SCOPE.OWNER_AS_CASTER,
|
||||
priority: EventPriorityLevel.ACTION_TRIGGER,
|
||||
eventType: GameplayTags.EVENT.ROUND_POST,
|
||||
scope: GameplayTags.SCOPE.GLOBAL,
|
||||
priority: EventPriorityLevel.ROUND_POST_DRAIN,
|
||||
mapping: { caster: 'owner', target: 'owner' },
|
||||
effects: [
|
||||
periodic,
|
||||
@@ -1486,6 +1487,7 @@ function compileUtilityAbilities(
|
||||
const renewalTick =
|
||||
0.03 * settings.woodHealingMultiplier * settings.loadoutMultiplier;
|
||||
const renewalBuff = buff('sect.tianyan.renewal', '回春', BuffType.BUFF, 2, {
|
||||
durationUnit: 'round',
|
||||
tags: [
|
||||
GameplayTags.BUFF.TYPE.BUFF,
|
||||
GameplayTags.BUFF.SECT.namespace(TIANYAN_SECT_ID, 'renewal'),
|
||||
@@ -1494,9 +1496,9 @@ function compileUtilityAbilities(
|
||||
listeners: [
|
||||
{
|
||||
id: 'sect.tianyan.renewal.tick',
|
||||
eventType: GameplayTags.EVENT.ACTION_PRE,
|
||||
scope: GameplayTags.SCOPE.OWNER_AS_CASTER,
|
||||
priority: EventPriorityLevel.ACTION_TRIGGER,
|
||||
eventType: GameplayTags.EVENT.ROUND_POST,
|
||||
scope: GameplayTags.SCOPE.GLOBAL,
|
||||
priority: EventPriorityLevel.ROUND_POST_RECOVERY,
|
||||
mapping: { caster: 'owner', target: 'owner' },
|
||||
effects: [
|
||||
{
|
||||
|
||||
@@ -4,6 +4,7 @@ import { withBattleRandomSource } from '@shared/engine/battle-v5/core/BattleRand
|
||||
import { EventBus } from '@shared/engine/battle-v5/core/EventBus';
|
||||
import type {
|
||||
ActionPreEvent,
|
||||
RoundPostEvent,
|
||||
BuffImmuneEvent,
|
||||
ControlResistEvent,
|
||||
DamageTakenEvent,
|
||||
@@ -238,16 +239,18 @@ describe('天衍落印术与反应实际结算', () => {
|
||||
.find((candidate) => candidate.id === 'sect.tianyan.burn');
|
||||
expect(burn()?.getLayer()).toBe(2);
|
||||
|
||||
EventBus.instance.publish<ActionPreEvent>({
|
||||
type: 'ActionPreEvent',
|
||||
beginRuntimeAction(enemy);
|
||||
EventBus.instance.publish<RoundPostEvent>({
|
||||
type: 'RoundPostEvent',
|
||||
timestamp: Date.now(),
|
||||
caster: enemy,
|
||||
turn: 1,
|
||||
});
|
||||
expect(burn()?.getLayer()).toBe(1);
|
||||
EventBus.instance.publish<ActionPreEvent>({
|
||||
type: 'ActionPreEvent',
|
||||
beginRuntimeAction(enemy);
|
||||
EventBus.instance.publish<RoundPostEvent>({
|
||||
type: 'RoundPostEvent',
|
||||
timestamp: Date.now(),
|
||||
caster: enemy,
|
||||
turn: 2,
|
||||
});
|
||||
expect(burn()).toBeUndefined();
|
||||
});
|
||||
@@ -261,10 +264,11 @@ describe('天衍落印术与反应实际结算', () => {
|
||||
const { owner, enemy, skill } = setup('luoshu-control');
|
||||
cast(skill('flowing-flame'), owner, enemy);
|
||||
if (layers === 1) {
|
||||
EventBus.instance.publish<ActionPreEvent>({
|
||||
type: 'ActionPreEvent',
|
||||
beginRuntimeAction(enemy);
|
||||
EventBus.instance.publish<RoundPostEvent>({
|
||||
type: 'RoundPostEvent',
|
||||
timestamp: Date.now(),
|
||||
caster: enemy,
|
||||
turn: 1,
|
||||
});
|
||||
}
|
||||
const requests: Array<{ baseDamage: number; cause?: { id: string } }> =
|
||||
@@ -540,12 +544,12 @@ describe('天衍落印术与反应实际结算', () => {
|
||||
|
||||
cast(skill('myriad-wood-renewal'), owner, owner);
|
||||
const afterImmediate = owner.getCurrentHp();
|
||||
for (let action = 0; action < 2; action += 1) {
|
||||
for (let turn = 0; turn < 2; turn += 1) {
|
||||
beginRuntimeAction(owner);
|
||||
EventBus.instance.publish<ActionPreEvent>({
|
||||
type: 'ActionPreEvent',
|
||||
EventBus.instance.publish<RoundPostEvent>({
|
||||
type: 'RoundPostEvent',
|
||||
timestamp: Date.now(),
|
||||
caster: owner,
|
||||
turn: turn + 1,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ import type { ActiveSkill } from '@shared/engine/battle-v5/abilities/ActiveSkill
|
||||
import { EventBus } from '@shared/engine/battle-v5/core/EventBus';
|
||||
import type {
|
||||
ActionPreEvent,
|
||||
RoundPostEvent,
|
||||
DamageRequestEvent,
|
||||
DamageTakenEvent,
|
||||
RoundStartEvent,
|
||||
@@ -488,12 +489,12 @@ describe('天衍衍数与双道途实际结算', () => {
|
||||
cast(skill('myriad-wood-renewal'), owner, owner);
|
||||
expect(owner.getCurrentShield()).toBe(0);
|
||||
|
||||
for (let action = 0; action < 2; action += 1) {
|
||||
for (let turn = 0; turn < 2; turn += 1) {
|
||||
beginRuntimeAction(owner);
|
||||
EventBus.instance.publish<ActionPreEvent>({
|
||||
type: 'ActionPreEvent',
|
||||
EventBus.instance.publish<RoundPostEvent>({
|
||||
type: 'RoundPostEvent',
|
||||
timestamp: Date.now(),
|
||||
caster: owner,
|
||||
turn: turn + 1,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -371,7 +371,8 @@ function forgetfulRiverBuff(settings: YouduBuildSettings): BuffConfig {
|
||||
return {
|
||||
id: YOUDU_FORGETFUL_RIVER,
|
||||
name: '忘川',
|
||||
description: '每次行动前受到魂伤,且受到的气血治疗降低。',
|
||||
description: '每回合结束受到魂伤,且受到的气血治疗降低。',
|
||||
durationUnit: 'round',
|
||||
type: BuffType.DEBUFF,
|
||||
duration: settings.forgetDuration,
|
||||
stackRule: StackRule.REFRESH_DURATION,
|
||||
@@ -394,9 +395,9 @@ function forgetfulRiverBuff(settings: YouduBuildSettings): BuffConfig {
|
||||
listeners: [
|
||||
{
|
||||
id: 'sect.youdu.forgetful-river-dot',
|
||||
eventType: GameplayTags.EVENT.ACTION_PRE,
|
||||
scope: GameplayTags.SCOPE.OWNER_AS_ACTOR,
|
||||
priority: EventPriorityLevel.ROUND_PRE,
|
||||
eventType: GameplayTags.EVENT.ROUND_POST,
|
||||
scope: GameplayTags.SCOPE.GLOBAL,
|
||||
priority: EventPriorityLevel.ROUND_POST_DRAIN,
|
||||
mapping: { caster: 'owner', target: 'owner' },
|
||||
effects: damageEffects,
|
||||
},
|
||||
@@ -1165,7 +1166,7 @@ function compileAbilities(
|
||||
extraTags: [soulDamageTag, erosionGeneratorTag],
|
||||
detailRows: [
|
||||
`${settings.forgetDirectCoefficient.toFixed(2)} × 法术攻击(魂伤)`,
|
||||
`忘川持续${settings.forgetDuration}回合,每次行动前造成${coefficient(settings.forgetDotCoefficient)} × 法术攻击(魂伤)`,
|
||||
`忘川持续${settings.forgetDuration}回合,每回合结束造成${coefficient(settings.forgetDotCoefficient)} × 法术攻击(魂伤)`,
|
||||
`受治疗效果额外降低${Math.round(settings.forgetHealReduction * 100)}%`,
|
||||
...(settings.forgetSpeedReduction < 0
|
||||
? [`忘川期间速度降低${percentage(-settings.forgetSpeedReduction)}`]
|
||||
|
||||
@@ -3,6 +3,7 @@ import { EventBus } from '@shared/engine/battle-v5/core/EventBus';
|
||||
import type {
|
||||
DamageRequestEvent,
|
||||
ResourceDrainEvent,
|
||||
RoundPostEvent,
|
||||
} from '@shared/engine/battle-v5/core/events';
|
||||
import { EventPriorityLevel } from '@shared/engine/battle-v5/core/events';
|
||||
import {
|
||||
@@ -776,9 +777,11 @@ describe('幽都核心机制实际结算', () => {
|
||||
requests.length = 0;
|
||||
|
||||
setOverride(caster, AttributeType.MAGIC_ATK, 100);
|
||||
EventBus.instance.publish({ type: 'ActionPreEvent', timestamp: Date.now(), caster: target });
|
||||
beginRuntimeAction(target);
|
||||
EventBus.instance.publish<RoundPostEvent>({ type: 'RoundPostEvent', timestamp: Date.now(), turn: 1 });
|
||||
setOverride(caster, AttributeType.MAGIC_ATK, 200);
|
||||
EventBus.instance.publish({ type: 'ActionPreEvent', timestamp: Date.now(), caster: target });
|
||||
beginRuntimeAction(target);
|
||||
EventBus.instance.publish<RoundPostEvent>({ type: 'RoundPostEvent', timestamp: Date.now(), turn: 2 });
|
||||
|
||||
expect(requests.map((event) => event.baseDamage)).toEqual([14, 28]);
|
||||
expect(requests.every((event) => event.canCrit === false)).toBe(true);
|
||||
|
||||
@@ -194,6 +194,7 @@ export const GameplayTags = {
|
||||
DAMAGE_REQUEST: 'DamageRequestEvent',
|
||||
DAMAGE: 'DamageEvent',
|
||||
ROUND_PRE: 'RoundPreEvent',
|
||||
ROUND_POST: 'RoundPostEvent',
|
||||
ROUND_START: 'RoundStartEvent',
|
||||
SKILL_PRE_CAST: 'SkillPreCastEvent',
|
||||
SKILL_CAST: 'SkillCastEvent',
|
||||
|
||||
Reference in new issue
Block a user