Merge pull request #57 from ChurchTao/codex/worktreebug

Add round-based buff durations and periodic effect timing
This commit is contained in:
桃树夭 authored and GitHub committed 2026-08-20 23:17:24 +08:00
commit 87f4c4b8c4
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 {
+6 -1
View File
@@ -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',