From b90381ec954de6314ddf0e0ae1f6e9d032e39a59 Mon Sep 17 00:00:00 2001 From: apache1123 Date: Wed, 20 Mar 2024 14:54:05 +1300 Subject: [PATCH 1/4] Weapon attack element can follow previous weapon element --- src/constants/elemental-type.ts | 2 - src/constants/weapon-definitions.ts | 123 ++++++++++++------ src/models/team.ts | 6 +- .../combat-simulator.test.ts.snap | 61 +++++---- .../v4/__tests__/combat-simulator.test.ts | 51 +++++++- src/models/v4/attack-definition.ts | 6 +- src/models/v4/attack.ts | 4 + src/models/v4/combat-simulator.ts | 58 ++++++--- src/models/v4/timeline/attack-event.ts | 21 ++- src/models/weapon-definition.ts | 4 +- 10 files changed, 236 insertions(+), 100 deletions(-) diff --git a/src/constants/elemental-type.ts b/src/constants/elemental-type.ts index 28f9025c..f2c2070f 100644 --- a/src/constants/elemental-type.ts +++ b/src/constants/elemental-type.ts @@ -3,5 +3,3 @@ export type CoreElementalType = 'Flame' | 'Frost' | 'Physical' | 'Volt'; export type WeaponElementalType = CoreElementalType | 'Altered'; export type ElementalType = WeaponElementalType | 'None' | 'All'; - -export type AttackDefinitionElementalType = WeaponElementalType | 'LastWeapon'; diff --git a/src/constants/weapon-definitions.ts b/src/constants/weapon-definitions.ts index 7209800c..30ea7544 100644 --- a/src/constants/weapon-definitions.ts +++ b/src/constants/weapon-definitions.ts @@ -88,7 +88,8 @@ export const weaponDefinitions: Data = { Alyss: { id: 'Alyss', displayName: 'Alyss', - elementalTypes: ['Frost'], + resonanceElements: ['Frost'], + damageElement: 'Frost', type: 'DPS', attackPercentBuffs: [ { @@ -126,7 +127,8 @@ export const weaponDefinitions: Data = { Annabella: { id: 'Annabella', displayName: 'Annabella', - elementalTypes: ['Flame'], + resonanceElements: ['Flame'], + damageElement: 'Flame', type: 'DPS', attackPercentBuffs: [ { @@ -187,7 +189,8 @@ export const weaponDefinitions: Data = { Asuka: { id: 'Asuka', displayName: 'Asuka', - elementalTypes: ['Physical', 'Flame'], + resonanceElements: ['Physical', 'Flame'], + damageElement: 'Physical', type: 'Defense', attackPercentBuffs: [ { @@ -238,7 +241,8 @@ export const weaponDefinitions: Data = { Brevey: { id: 'Brevey', displayName: 'Brevey', - elementalTypes: ['Volt', 'Frost'], + resonanceElements: ['Volt', 'Frost'], + damageElement: 'Volt', type: 'Support', attackPercentBuffs: [ { @@ -325,7 +329,8 @@ export const weaponDefinitions: Data = { Claudia: { id: 'Claudia', displayName: 'Claudia', - elementalTypes: ['Physical'], + resonanceElements: ['Physical'], + damageElement: 'Physical', type: 'DPS', attackPercentBuffs: [ { @@ -364,7 +369,8 @@ export const weaponDefinitions: Data = { 'Cobalt-B': { id: 'Cobalt-B', displayName: 'Cobalt-B', - elementalTypes: ['Flame'], + resonanceElements: ['Flame'], + damageElement: 'Flame', type: 'DPS', attackPercentBuffs: [ { @@ -402,7 +408,8 @@ export const weaponDefinitions: Data = { Cocoritter: { id: 'Cocoritter', displayName: 'Cocoritter', - elementalTypes: ['Frost'], + resonanceElements: ['Frost'], + damageElement: 'Frost', type: 'Support', attackPercentBuffs: [ { @@ -453,7 +460,8 @@ export const weaponDefinitions: Data = { Crow: { id: 'Crow', displayName: 'Crow', - elementalTypes: ['Volt'], + resonanceElements: ['Volt'], + damageElement: 'Volt', type: 'DPS', attackPercentBuffs: [], critRateBuffs: [], @@ -478,7 +486,8 @@ export const weaponDefinitions: Data = { 'Fei Se': { id: 'Fei Se', displayName: 'Fei Se', - elementalTypes: ['Flame'], + resonanceElements: ['Flame'], + damageElement: 'Flame', type: 'DPS', attackPercentBuffs: [ { @@ -528,7 +537,8 @@ export const weaponDefinitions: Data = { Fenrir: { id: 'Fenrir', displayName: 'Fenrir', - elementalTypes: ['Volt'], + resonanceElements: ['Volt'], + damageElement: 'Volt', type: 'DPS', attackPercentBuffs: [ { @@ -590,7 +600,8 @@ export const weaponDefinitions: Data = { Fiona: { id: 'Fiona', displayName: 'Fiona', - elementalTypes: ['Altered'], + resonanceElements: ['Altered'], + damageElement: 'Altered', type: 'Support', attackPercentBuffs: [ { @@ -640,7 +651,8 @@ export const weaponDefinitions: Data = { Frigg: { id: 'Frigg', displayName: 'Frigg', - elementalTypes: ['Frost'], + resonanceElements: ['Frost'], + damageElement: 'Frost', type: 'DPS', attackPercentBuffs: [ { @@ -700,7 +712,8 @@ export const weaponDefinitions: Data = { Gnonno: { id: 'Gnonno', displayName: 'Gnonno', - elementalTypes: ['Physical'], + resonanceElements: ['Physical'], + damageElement: 'Physical', type: 'DPS', attackPercentBuffs: [ { @@ -739,7 +752,8 @@ export const weaponDefinitions: Data = { 'Huang (Mimi)': { id: 'Huang (Mimi)', displayName: 'Huang (Mimi)', - elementalTypes: ['Volt'], + resonanceElements: ['Volt'], + damageElement: 'Volt', type: 'Defense', attackPercentBuffs: [ { @@ -790,7 +804,8 @@ export const weaponDefinitions: Data = { Huma: { id: 'Huma', displayName: 'Huma', - elementalTypes: ['Flame'], + resonanceElements: ['Flame'], + damageElement: 'Flame', type: 'Defense', attackPercentBuffs: [], critRateBuffs: [], @@ -815,7 +830,8 @@ export const weaponDefinitions: Data = { Icarus: { id: 'Icarus', displayName: 'Icarus', - elementalTypes: ['Frost'], + resonanceElements: ['Frost'], + damageElement: 'Frost', type: 'DPS', attackPercentBuffs: [ { @@ -853,7 +869,8 @@ export const weaponDefinitions: Data = { King: { id: 'King', displayName: 'King', - elementalTypes: ['Flame'], + resonanceElements: ['Flame'], + damageElement: 'Flame', type: 'DPS', attackPercentBuffs: [], critRateBuffs: [], @@ -878,7 +895,8 @@ export const weaponDefinitions: Data = { Lan: { id: 'Lan', displayName: 'Lan', - elementalTypes: ['Flame'], + resonanceElements: ['Flame'], + damageElement: 'Flame', type: 'Defense', attackPercentBuffs: [ { @@ -916,7 +934,8 @@ export const weaponDefinitions: Data = { Lin: { id: 'Lin', displayName: 'Lin', - elementalTypes: ['Altered'], + resonanceElements: ['Altered'], + damageElement: 'Altered', type: 'DPS', attackPercentBuffs: [ { @@ -989,7 +1008,8 @@ export const weaponDefinitions: Data = { 'Ling Han': { id: 'Ling Han', displayName: 'Ling Han', - elementalTypes: ['Frost'], + resonanceElements: ['Frost'], + damageElement: 'Frost', type: 'DPS', attackPercentBuffs: [ { @@ -1038,7 +1058,8 @@ export const weaponDefinitions: Data = { 'Liu Huo': { id: 'Liu Huo', displayName: 'Liu Huo', - elementalTypes: ['Flame'], + resonanceElements: ['Flame'], + damageElement: 'Flame', type: 'DPS', attackPercentBuffs: [ { @@ -1076,7 +1097,8 @@ export const weaponDefinitions: Data = { Lyra: { id: 'Lyra', displayName: 'Lyra', - elementalTypes: ['Physical'], + resonanceElements: ['Physical'], + damageElement: 'Physical', type: 'Support', attackPercentBuffs: [ { @@ -1128,7 +1150,8 @@ export const weaponDefinitions: Data = { Meryl: { id: 'Meryl', displayName: 'Meryl', - elementalTypes: ['Frost'], + resonanceElements: ['Frost'], + damageElement: 'Frost', type: 'Defense', attackPercentBuffs: [], critRateBuffs: [], @@ -1153,7 +1176,8 @@ export const weaponDefinitions: Data = { 'Ming Jing': { id: 'Ming Jing', displayName: 'Ming Jing (Zeke)', - elementalTypes: ['Physical', 'Flame'], + resonanceElements: ['Physical', 'Flame'], + damageElement: 'Physical', type: 'DPS', attackPercentBuffs: [ { @@ -1216,7 +1240,8 @@ export const weaponDefinitions: Data = { 'Nan Yin': { id: 'Nan Yin', displayName: 'Nan Yin', - elementalTypes: ['Altered'], + resonanceElements: ['Altered'], + damageElement: 'Altered', type: 'DPS', attackPercentBuffs: [ { @@ -1243,7 +1268,8 @@ export const weaponDefinitions: Data = { { id: 'nanyin-normal-auto-chain', displayName: 'Nan Yin - Auto chain', - elementalType: 'LastWeapon', + elementalType: 'Altered', + followLastWeaponElementalType: true, type: 'normal', attackMultiplier: 11.98, attackFlat: 63, @@ -1258,7 +1284,8 @@ export const weaponDefinitions: Data = { discharge: { id: 'nanyin-discharge', displayName: 'Nan Yin - discharge', - elementalType: 'LastWeapon', + elementalType: 'Altered', + followLastWeaponElementalType: true, type: 'discharge', attackMultiplier: 0, attackFlat: 0, @@ -1295,7 +1322,8 @@ export const weaponDefinitions: Data = { Nemesis: { id: 'Nemesis', displayName: 'Nemesis', - elementalTypes: ['Volt'], + resonanceElements: ['Volt'], + damageElement: 'Volt', type: 'Support', attackPercentBuffs: [ { @@ -1368,7 +1396,8 @@ export const weaponDefinitions: Data = { Plotti: { id: 'Plotti', displayName: 'Plotti', - elementalTypes: ['Flame', 'Physical'], + resonanceElements: ['Flame', 'Physical'], + damageElement: 'Flame', type: 'DPS', attackPercentBuffs: [ { @@ -1419,7 +1448,8 @@ export const weaponDefinitions: Data = { Rubilia: { id: 'Rubilia', displayName: 'Rubilia', - elementalTypes: ['Volt'], + resonanceElements: ['Volt'], + damageElement: 'Volt', type: 'DPS', attackPercentBuffs: [ { @@ -1457,7 +1487,8 @@ export const weaponDefinitions: Data = { Ruby: { id: 'Ruby', displayName: 'Ruby', - elementalTypes: ['Flame'], + resonanceElements: ['Flame'], + damageElement: 'Flame', type: 'DPS', attackPercentBuffs: [ { @@ -1518,7 +1549,8 @@ export const weaponDefinitions: Data = { 'Saki Fuwa': { id: 'Saki Fuwa', displayName: 'Saki Fuwa', - elementalTypes: ['Frost'], + resonanceElements: ['Frost'], + damageElement: 'Frost', type: 'DPS', attackPercentBuffs: [ { @@ -1556,7 +1588,8 @@ export const weaponDefinitions: Data = { Samir: { id: 'Samir', displayName: 'Samir', - elementalTypes: ['Volt'], + resonanceElements: ['Volt'], + damageElement: 'Volt', type: 'DPS', attackPercentBuffs: [], critRateBuffs: [], @@ -1581,7 +1614,8 @@ export const weaponDefinitions: Data = { Shiro: { id: 'Shiro', displayName: 'Shiro', - elementalTypes: ['Physical'], + resonanceElements: ['Physical'], + damageElement: 'Physical', type: 'DPS', attackPercentBuffs: [], critRateBuffs: [], @@ -1606,7 +1640,8 @@ export const weaponDefinitions: Data = { 'Tian Lang': { id: 'Tian Lang', displayName: 'Tian Lang', - elementalTypes: ['Volt'], + resonanceElements: ['Volt'], + damageElement: 'Volt', type: 'DPS', attackPercentBuffs: [ { @@ -1657,7 +1692,8 @@ export const weaponDefinitions: Data = { Tsubasa: { id: 'Tsubasa', displayName: 'Tsubasa', - elementalTypes: ['Frost'], + resonanceElements: ['Frost'], + damageElement: 'Frost', type: 'DPS', attackPercentBuffs: [], critRateBuffs: [], @@ -1682,7 +1718,8 @@ export const weaponDefinitions: Data = { Umi: { id: 'Umi', displayName: 'Umi', - elementalTypes: ['Physical'], + resonanceElements: ['Physical'], + damageElement: 'Physical', type: 'DPS', attackPercentBuffs: [ { @@ -1733,7 +1770,8 @@ export const weaponDefinitions: Data = { 'Yan Miao': { id: 'Yan Miao', displayName: 'Yan Miao', - elementalTypes: ['Physical', 'Flame'], + resonanceElements: ['Physical', 'Flame'], + damageElement: 'Physical', type: 'DPS', attackPercentBuffs: [ { @@ -1796,7 +1834,8 @@ export const weaponDefinitions: Data = { Yanuo: { id: 'Yanuo', displayName: 'Yanuo', - elementalTypes: ['Frost', 'Volt'], + resonanceElements: ['Frost', 'Volt'], + damageElement: 'Frost', type: 'DPS', attackPercentBuffs: [ { @@ -1871,7 +1910,8 @@ export const weaponDefinitions: Data = { 'Yu Lan': { id: 'Yu Lan', displayName: 'Yu Lan', - elementalTypes: ['Frost'], + resonanceElements: ['Frost'], + damageElement: 'Frost', type: 'DPS', attackPercentBuffs: [ { @@ -1909,7 +1949,8 @@ export const weaponDefinitions: Data = { Zero: { id: 'Zero', displayName: 'Zero', - elementalTypes: ['Flame'], + resonanceElements: ['Flame'], + damageElement: 'Flame', type: 'Support', attackPercentBuffs: [ { diff --git a/src/models/team.ts b/src/models/team.ts index ab974ae2..2e578c61 100644 --- a/src/models/team.ts +++ b/src/models/team.ts @@ -26,14 +26,16 @@ export class Team implements Persistable { } /** Convenience method to return all equipped weapon elemental types, as is. Useful for counting the number of weapons for a given elemental type */ public get weaponElementalTypes(): WeaponElementalType[] { - return this.weapons.flatMap((weapon) => weapon.definition.elementalTypes); + return this.weapons.flatMap( + (weapon) => weapon.definition.resonanceElements + ); } // TODO: remove this when old buff definitions are replaced by v4 buff definitions /** This returns the presumed elemental resonance(s), depending on the number of weapons of each element. However, whether or not to activate elemental resonance buff(s) will depend on if the weapons themselves have the buff(s) available. */ public get elementalResonances(): ElementalResonance[] { const elementalTypes = this.weapons.flatMap( - (weapon) => weapon.definition.elementalTypes + (weapon) => weapon.definition.resonanceElements ); const elementalTypeGroups = groupBy(elementalTypes); diff --git a/src/models/v4/__tests__/__snapshots__/combat-simulator.test.ts.snap b/src/models/v4/__tests__/__snapshots__/combat-simulator.test.ts.snap index b6440c6e..7089c67c 100644 --- a/src/models/v4/__tests__/__snapshots__/combat-simulator.test.ts.snap +++ b/src/models/v4/__tests__/__snapshots__/combat-simulator.test.ts.snap @@ -59,6 +59,7 @@ exports[`CombatSimulator available attacks includes attacks from all weapons at "force-impact", ], "critRateBuffs": [], + "damageElement": "Volt", "discharge": { "attackFlat": 0, "attackMultiplier": 0, @@ -84,10 +85,6 @@ exports[`CombatSimulator available attacks includes attacks from all weapons at "type": "dodge", }, ], - "elementalTypes": [ - "Volt", - "Frost", - ], "id": "Brevey", "normalAttacks": [ { @@ -102,6 +99,10 @@ exports[`CombatSimulator available attacks includes attacks from all weapons at "type": "normal", }, ], + "resonanceElements": [ + "Volt", + "Frost", + ], "skills": [ { "attackFlat": 31, @@ -178,6 +179,7 @@ exports[`CombatSimulator available attacks includes attacks from all weapons at "force-impact", ], "critRateBuffs": [], + "damageElement": "Volt", "discharge": { "attackFlat": 0, "attackMultiplier": 0, @@ -203,10 +205,6 @@ exports[`CombatSimulator available attacks includes attacks from all weapons at "type": "dodge", }, ], - "elementalTypes": [ - "Volt", - "Frost", - ], "id": "Brevey", "normalAttacks": [ { @@ -221,6 +219,10 @@ exports[`CombatSimulator available attacks includes attacks from all weapons at "type": "normal", }, ], + "resonanceElements": [ + "Volt", + "Frost", + ], "skills": [ { "attackFlat": 31, @@ -297,6 +299,7 @@ exports[`CombatSimulator available attacks includes attacks from all weapons at "force-impact", ], "critRateBuffs": [], + "damageElement": "Volt", "discharge": { "attackFlat": 0, "attackMultiplier": 0, @@ -322,10 +325,6 @@ exports[`CombatSimulator available attacks includes attacks from all weapons at "type": "dodge", }, ], - "elementalTypes": [ - "Volt", - "Frost", - ], "id": "Brevey", "normalAttacks": [ { @@ -340,6 +339,10 @@ exports[`CombatSimulator available attacks includes attacks from all weapons at "type": "normal", }, ], + "resonanceElements": [ + "Volt", + "Frost", + ], "skills": [ { "attackFlat": 31, @@ -414,6 +417,7 @@ exports[`CombatSimulator available attacks includes attacks from all weapons at ], "commonDamageBuffs": [], "critRateBuffs": [], + "damageElement": "Frost", "discharge": { "attackFlat": 0, "attackMultiplier": 0, @@ -427,10 +431,6 @@ exports[`CombatSimulator available attacks includes attacks from all weapons at }, "displayName": "Yanuo", "dodgeAttacks": [], - "elementalTypes": [ - "Frost", - "Volt", - ], "id": "Yanuo", "normalAttacks": [ { @@ -445,6 +445,10 @@ exports[`CombatSimulator available attacks includes attacks from all weapons at "type": "normal", }, ], + "resonanceElements": [ + "Frost", + "Volt", + ], "skills": [ { "attackFlat": 0, @@ -519,6 +523,7 @@ exports[`CombatSimulator available attacks includes attacks from all weapons at ], "commonDamageBuffs": [], "critRateBuffs": [], + "damageElement": "Frost", "discharge": { "attackFlat": 0, "attackMultiplier": 0, @@ -532,10 +537,6 @@ exports[`CombatSimulator available attacks includes attacks from all weapons at }, "displayName": "Yanuo", "dodgeAttacks": [], - "elementalTypes": [ - "Frost", - "Volt", - ], "id": "Yanuo", "normalAttacks": [ { @@ -550,6 +551,10 @@ exports[`CombatSimulator available attacks includes attacks from all weapons at "type": "normal", }, ], + "resonanceElements": [ + "Frost", + "Volt", + ], "skills": [ { "attackFlat": 0, @@ -577,7 +582,8 @@ exports[`CombatSimulator available attacks includes attacks from all weapons at "cooldown": 0, "displayName": "Nan Yin - Auto chain", "duration": 6000, - "elementalType": "LastWeapon", + "elementalType": "Altered", + "followLastWeaponElementalType": true, "id": "nanyin-normal-auto-chain", "type": "normal", }, @@ -640,6 +646,7 @@ exports[`CombatSimulator available attacks includes attacks from all weapons at "commonAttackBuffs": [], "commonDamageBuffs": [], "critRateBuffs": [], + "damageElement": "Altered", "discharge": { "attackFlat": 0, "attackMultiplier": 0, @@ -647,15 +654,13 @@ exports[`CombatSimulator available attacks includes attacks from all weapons at "cooldown": 30000, "displayName": "Nan Yin - discharge", "duration": 5000, - "elementalType": "LastWeapon", + "elementalType": "Altered", + "followLastWeaponElementalType": true, "id": "nanyin-discharge", "type": "discharge", }, "displayName": "Nan Yin", "dodgeAttacks": [], - "elementalTypes": [ - "Altered", - ], "id": "Nan Yin", "normalAttacks": [ { @@ -665,11 +670,15 @@ exports[`CombatSimulator available attacks includes attacks from all weapons at "cooldown": 0, "displayName": "Nan Yin - Auto chain", "duration": 6000, - "elementalType": "LastWeapon", + "elementalType": "Altered", + "followLastWeaponElementalType": true, "id": "nanyin-normal-auto-chain", "type": "normal", }, ], + "resonanceElements": [ + "Altered", + ], "skills": [], "type": "DPS", }, diff --git a/src/models/v4/__tests__/combat-simulator.test.ts b/src/models/v4/__tests__/combat-simulator.test.ts index b0376936..c3c97a2d 100644 --- a/src/models/v4/__tests__/combat-simulator.test.ts +++ b/src/models/v4/__tests__/combat-simulator.test.ts @@ -1,4 +1,5 @@ import { fullCharge, maxCharge } from '../../../constants/combat'; +import type { WeaponElementalType } from '../../../constants/elemental-type'; import { simulacrumTraits } from '../../../constants/simulacrum-traits'; import { weaponDefinitions } from '../../../constants/weapon-definitions'; import { repeat } from '../../../utils/test-utils'; @@ -6,7 +7,6 @@ import { GearSet } from '../../gear-set'; import { Loadout } from '../../loadout'; import { Team } from '../../team'; import { Weapon } from '../../weapon'; -import type { Attack } from '../attack'; import type { AttackDefinition } from '../attack-definition'; import { CombatSimulator } from '../combat-simulator'; import { Relics } from '../relics'; @@ -67,7 +67,21 @@ describe('CombatSimulator', () => { it('does not include attacks if they are on cooldown', () => { const sut = new CombatSimulator(combatDuration, loadout, relics); - const attack: Attack = { + // sut.performAttack({ + // weapon: weapon1, + // attackDefinition: weapon1.definition.skills[0], + // }); + + // expect( + // sut.availableAttacks + // .get(weapon1) + // ?.some( + // (attackDefinition) => + // attackDefinition.id === weapon1.definition.skills[0].id + // ) + // ).toBe(false); + + const attack = { weapon: weapon1, attackDefinition: weapon1.definition.skills[0], }; @@ -79,6 +93,9 @@ describe('CombatSimulator', () => { it('does not include discharges if there is no full charge available', () => { const sut = new CombatSimulator(combatDuration, loadout, relics); expect( + // Array.from(sut.availableAttacks.values()) + // .flat() + // .some((attackDefinition) => attackDefinition.type === 'discharge') sut.availableAttacks.some( (attack) => attack.attackDefinition.type === 'discharge' ) @@ -89,6 +106,9 @@ describe('CombatSimulator', () => { attackDefinition: weapon1.definition.normalAttacks[0], }); expect( + // Array.from(sut.availableAttacks.values()) + // .flat() + // .some((attackDefinition) => attackDefinition.type === 'discharge') sut.availableAttacks.some( (attack) => attack.attackDefinition.type === 'discharge' ) @@ -105,6 +125,9 @@ describe('CombatSimulator', () => { }); }, 20); + // const dischargeAttacks = Array.from(sut.availableAttacks.values()) + // .flat() + // .filter((attackDefinition) => attackDefinition.type === 'discharge'); const dischargeAttacks = sut.availableAttacks.filter( (attack) => attack.attackDefinition.type === 'discharge' ); @@ -126,6 +149,30 @@ describe('CombatSimulator', () => { }); }).toThrow(); }); + + it("changes the attack's element to follow that of the previous weapon", () => { + // Test nan yin attacks + const sut = new CombatSimulator(combatDuration, loadout, relics); + sut.performAttack({ + weapon: weapon3, + attackDefinition: weapon3.definition.normalAttacks[0], + }); + expect( + sut.attackTimelines.get(weapon3)?.lastEvent?.elementalType + ).toBe('Altered'); + + sut.performAttack({ + weapon: weapon1, + attackDefinition: weapon1.definition.normalAttacks[0], + }); + sut.performAttack({ + weapon: weapon3, + attackDefinition: weapon3.definition.normalAttacks[0], + }); + expect( + sut.attackTimelines.get(weapon3)?.lastEvent?.elementalType + ).toBe('Volt'); + }); }); describe('common weapon damage buff', () => { diff --git a/src/models/v4/attack-definition.ts b/src/models/v4/attack-definition.ts index 7b247d95..e1e40a32 100644 --- a/src/models/v4/attack-definition.ts +++ b/src/models/v4/attack-definition.ts @@ -1,12 +1,14 @@ import type { AttackType } from '../../constants/attack-type'; -import type { AttackDefinitionElementalType } from '../../constants/elemental-type'; +import type { WeaponElementalType } from '../../constants/elemental-type'; export interface AttackDefinition { id: string; displayName: string; description?: string; - elementalType: AttackDefinitionElementalType; + elementalType: WeaponElementalType; + followLastWeaponElementalType?: boolean; + type: AttackType; /** the "x%" part of "dealing damage equal to x% of ATK plus y to target" / `base damage = ATK * x% + y` */ diff --git a/src/models/v4/attack.ts b/src/models/v4/attack.ts index 87028463..0dc41a13 100644 --- a/src/models/v4/attack.ts +++ b/src/models/v4/attack.ts @@ -1,7 +1,11 @@ +import type { WeaponElementalType } from '../../constants/elemental-type'; import type { Weapon } from '../weapon'; import type { AttackDefinition } from './attack-definition'; export interface Attack { weapon: Weapon; attackDefinition: AttackDefinition; + /** The damage element of the attack. This is needed because it could be different from what's defined in the attack definition e.g. Nanyin's attack element is based off of the previous weapon */ + elementalType: WeaponElementalType; + cooldown: number; } diff --git a/src/models/v4/combat-simulator.ts b/src/models/v4/combat-simulator.ts index 5a375074..379b3126 100644 --- a/src/models/v4/combat-simulator.ts +++ b/src/models/v4/combat-simulator.ts @@ -27,6 +27,9 @@ export class CombatSimulator { public readonly chargeTimeline = new ChargeTimeline(); private _activeWeapon: Weapon | undefined; + /** The previous weapon before switching to the current active weapon */ + private _previousWeapon: Weapon | undefined; + /** Registered buff definitions will be checked whenever an attack happens. A buff event will be added to the timeline if the conditions defined in the buff definition are met */ private registeredBuffs: { buffDefinitions: BuffDefinition[]; @@ -49,8 +52,8 @@ export class CombatSimulator { return this._activeWeapon; } - public get availableAttacks(): Attack[] { - const allAttacks = this.loadout.team.weapons.flatMap((weapon): Attack[] => { + public get availableAttacks(): Pick[] { + const allAttacks = this.loadout.team.weapons.flatMap((weapon) => { const { definition: { normalAttacks, dodgeAttacks, skills, discharge }, } = weapon; @@ -86,7 +89,7 @@ export class CombatSimulator { nextEarliestAttackStartTime ) ?? []; return attackEventsToCheck.every( - (x) => x.attack.attackDefinition.id !== attack.attackDefinition.id + (x) => x.attackDefinition.id !== attack.attackDefinition.id ); }); } @@ -97,8 +100,10 @@ export class CombatSimulator { : 0; } - public performAttack(attack: Attack) { - const { weapon, attackDefinition } = attack; + public performAttack({ + weapon, + attackDefinition, + }: Pick) { if ( !this.availableAttacks.find( (availableAttack) => @@ -110,16 +115,31 @@ export class CombatSimulator { } const { nextEarliestAttackStartTime } = this; - const attackTimeline = this.attackTimelines.get(attack.weapon); + const attackTimeline = this.attackTimelines.get(weapon); if (!attackTimeline) { throw new Error('Weapon attack timeline not set up'); } - const attackEvent = new AttackEvent(nextEarliestAttackStartTime, attack); - attackTimeline.addEvent(attackEvent); + if (this._activeWeapon !== weapon) { + this._previousWeapon = this._activeWeapon; + this._activeWeapon = weapon; + } + + const attackEvent = new AttackEvent( + nextEarliestAttackStartTime, + weapon, + attackDefinition + ); - this._activeWeapon = weapon; + if ( + attackDefinition.followLastWeaponElementalType && + this._previousWeapon + ) { + attackEvent.elementalType = this._previousWeapon.definition.damageElement; + } + + attackTimeline.addEvent(attackEvent); // Register all buffs first at the start of combat if (attackEvent.startTime === 0) { @@ -131,11 +151,11 @@ export class CombatSimulator { } private adjustCharge(attackEvent: AttackEvent) { - if (attackEvent.attack.attackDefinition.type === 'discharge') { + if (attackEvent.attackDefinition.type === 'discharge') { this.chargeTimeline.deductOneFullCharge(attackEvent.startTime); } else { this.chargeTimeline.addCharge( - attackEvent.attack.attackDefinition.charge, + attackEvent.attackDefinition.charge, attackEvent.endTime ); } @@ -277,7 +297,7 @@ export class CombatSimulator { if (notElementalTypeWeaponRequirement) { const numOfNotElementalTypeWeapons = weapons.filter( (weapon) => - !weapon.definition.elementalTypes.includes( + !weapon.definition.resonanceElements.includes( notElementalTypeWeaponRequirement.notElementalType ) ).length; @@ -312,14 +332,12 @@ export class CombatSimulator { const { triggeredBy } = buffDefinition; const { - attack: { - attackDefinition: { id: attackId, type: attackType }, - weapon: { - definition: { - id: weaponId, - type: weaponType, - elementalTypes: weaponElementalTypes, - }, + attackDefinition: { id: attackId, type: attackType }, + weapon: { + definition: { + id: weaponId, + type: weaponType, + resonanceElements: weaponElementalTypes, }, }, } = attackEvent; diff --git a/src/models/v4/timeline/attack-event.ts b/src/models/v4/timeline/attack-event.ts index 0b6c3510..9c929519 100644 --- a/src/models/v4/timeline/attack-event.ts +++ b/src/models/v4/timeline/attack-event.ts @@ -1,12 +1,25 @@ +import type { WeaponElementalType } from '../../../constants/elemental-type'; +import type { Weapon } from '../../weapon'; import type { Attack } from '../attack'; +import type { AttackDefinition } from '../attack-definition'; import { TimelineEvent } from './timeline-event'; -export class AttackEvent extends TimelineEvent { - public constructor(public startTime: number, public attack: Attack) { - super(startTime, attack.attackDefinition.duration); +export class AttackEvent extends TimelineEvent implements Attack { + public elementalType: WeaponElementalType; + public cooldown: number; + + public constructor( + public startTime: number, + public readonly weapon: Weapon, + public readonly attackDefinition: AttackDefinition + ) { + super(startTime, attackDefinition.duration); + + this.elementalType = attackDefinition.elementalType; + this.cooldown = attackDefinition.cooldown; } public get displayName() { - return this.attack.attackDefinition.displayName; + return this.attackDefinition.displayName; } } diff --git a/src/models/weapon-definition.ts b/src/models/weapon-definition.ts index 4b970eb4..f010eb0e 100644 --- a/src/models/weapon-definition.ts +++ b/src/models/weapon-definition.ts @@ -21,7 +21,9 @@ export interface WeaponDefinition { /** The elemental type the weapon is considered to be for the purposes of elemental resonance, matrix effects etc. (not the damage dealing elemental type) * E.g. For Yan Miao, her weapon is considered to be both Physical and Flame to trigger Physical resonance and Flame resonance, but deals (mainly) physical damage. */ - elementalTypes: WeaponElementalType[]; + resonanceElements: WeaponElementalType[]; + /** The element the weapon deals damage in when it is on field */ + damageElement: WeaponElementalType; type: WeaponType; attackPercentBuffs: WeaponAttackPercentBuffDefinition[]; critRateBuffs: WeaponCritRateBuffDefinition[]; From 358c118d517363ae9cdfede72da8ffb9f985f9e7 Mon Sep 17 00:00:00 2001 From: apache1123 Date: Thu, 21 Mar 2024 16:59:41 +1300 Subject: [PATCH 2/4] Add weapon effect timeline for other effects to trigger off of; Add new not active weapon trigger --- src/constants/weapon-definitions.ts | 133 +++++++++++ .../combat-simulator.test.ts.snap | 221 ++++++++++++++++-- .../v4/__tests__/combat-simulator.test.ts | 70 ++++++ src/models/v4/buffs/attack-buff-definition.ts | 4 +- src/models/v4/buffs/damage-buff-definition.ts | 4 +- .../v4/buffs/miscellaneous-buff-definition.ts | 4 +- src/models/v4/combat-simulator.ts | 166 ++++++++----- ...uff-definition.ts => effect-definition.ts} | 19 +- ...meline.test.ts => effect-timeline.test.ts} | 56 +++-- .../v4/timeline/__tests__/timeline.test.ts | 9 + .../{buff-event.ts => effect-event.ts} | 8 +- .../{buff-timeline.ts => effect-timeline.ts} | 34 ++- src/models/v4/timeline/timeline.ts | 4 +- src/models/weapon-definition.ts | 5 + 14 files changed, 603 insertions(+), 134 deletions(-) rename src/models/v4/{buffs/buff-definition.ts => effect-definition.ts} (66%) rename src/models/v4/timeline/__tests__/{buff-timeline.test.ts => effect-timeline.test.ts} (51%) rename src/models/v4/timeline/{buff-event.ts => effect-event.ts} (58%) rename src/models/v4/timeline/{buff-timeline.ts => effect-timeline.ts} (63%) diff --git a/src/constants/weapon-definitions.ts b/src/constants/weapon-definitions.ts index 30ea7544..3feeb185 100644 --- a/src/constants/weapon-definitions.ts +++ b/src/constants/weapon-definitions.ts @@ -121,8 +121,10 @@ export const weaponDefinitions: Data = { charge: 0, }, commonDamageBuffs: [], + effects: [], commonAttackBuffs: [], attackBuffs: [], + damageBuffs: [], }, Annabella: { id: 'Annabella', @@ -183,8 +185,10 @@ export const weaponDefinitions: Data = { charge: 0, }, commonDamageBuffs: [], + effects: [], commonAttackBuffs: [], attackBuffs: [], + damageBuffs: [], }, Asuka: { id: 'Asuka', @@ -235,8 +239,10 @@ export const weaponDefinitions: Data = { charge: 0, }, commonDamageBuffs: [], + effects: [], commonAttackBuffs: [], attackBuffs: [], + damageBuffs: [], }, Brevey: { id: 'Brevey', @@ -322,9 +328,66 @@ export const weaponDefinitions: Data = { cooldown: 0, charge: 0, }, + effects: [ + { + id: 'brevey-effect-pact-amplification', + displayName: 'Brevey - Pact Amplification', + description: '', + maxStacks: 1, + triggeredBy: { + weaponAttacks: ['brevey-skill-million-metz-shockwave'], + }, + duration: { + value: 30000, + }, + cooldown: 30000, + }, + ], commonAttackBuffs: ['volt-resonance', 'frost-resonance'], commonDamageBuffs: ['force-impact'], attackBuffs: [], + damageBuffs: [ + { + id: 'brevey-damage-buff-pact-amplification-volt', + displayName: 'Brevey - Pact Amplification Volt Buff', + description: + "During Pact Amplification, when Pactcrest ☆ Metz is in the main slot, increase the Wanderer's volt damage by 25%.", + value: 0.25, + elementalTypes: ['Volt'], + damageCategory: '[TEMP_UNKNOWN]', + maxStacks: 1, + triggeredBy: { + activeWeapon: 'Brevey', + }, + duration: { + followActiveWeapon: true, + }, + cooldown: 0, + requirements: { + activeEffect: 'brevey-effect-pact-amplification', + }, + }, + { + id: 'brevey-damage-buff-pact-amplification-frost', + displayName: 'Brevey - Pact Amplification Frost Buff', + description: + "During Pact Amplification, when Pactcrest ☆ Metz is in the main slot, increase the Wanderer's frost damage by 10%.", + value: 0.25, + elementalTypes: ['Frost'], + damageCategory: '[TEMP_UNKNOWN]', + maxStacks: 1, + triggeredBy: { + notActiveWeapon: 'Brevey', + }, + duration: { + followActiveWeapon: true, + }, + cooldown: 0, + requirements: { + activeEffect: 'brevey-effect-pact-amplification', + }, + }, + ], }, Claudia: { id: 'Claudia', @@ -363,8 +426,10 @@ export const weaponDefinitions: Data = { charge: 0, }, commonDamageBuffs: [], + effects: [], commonAttackBuffs: [], attackBuffs: [], + damageBuffs: [], }, 'Cobalt-B': { id: 'Cobalt-B', @@ -402,8 +467,10 @@ export const weaponDefinitions: Data = { charge: 0, }, commonDamageBuffs: [], + effects: [], commonAttackBuffs: [], attackBuffs: [], + damageBuffs: [], }, Cocoritter: { id: 'Cocoritter', @@ -454,8 +521,10 @@ export const weaponDefinitions: Data = { charge: 0, }, commonDamageBuffs: [], + effects: [], commonAttackBuffs: [], attackBuffs: [], + damageBuffs: [], }, Crow: { id: 'Crow', @@ -480,8 +549,10 @@ export const weaponDefinitions: Data = { charge: 0, }, commonDamageBuffs: [], + effects: [], commonAttackBuffs: [], attackBuffs: [], + damageBuffs: [], }, 'Fei Se': { id: 'Fei Se', @@ -531,8 +602,10 @@ export const weaponDefinitions: Data = { charge: 0, }, commonDamageBuffs: [], + effects: [], commonAttackBuffs: [], attackBuffs: [], + damageBuffs: [], }, Fenrir: { id: 'Fenrir', @@ -594,8 +667,10 @@ export const weaponDefinitions: Data = { charge: 0, }, commonDamageBuffs: [], + effects: [], commonAttackBuffs: [], attackBuffs: [], + damageBuffs: [], }, Fiona: { id: 'Fiona', @@ -645,8 +720,10 @@ export const weaponDefinitions: Data = { charge: 0, }, commonDamageBuffs: [], + effects: [], commonAttackBuffs: [], attackBuffs: [], + damageBuffs: [], }, Frigg: { id: 'Frigg', @@ -706,8 +783,10 @@ export const weaponDefinitions: Data = { charge: 0, }, commonDamageBuffs: [], + effects: [], commonAttackBuffs: [], attackBuffs: [], + damageBuffs: [], }, Gnonno: { id: 'Gnonno', @@ -746,8 +825,10 @@ export const weaponDefinitions: Data = { charge: 0, }, commonDamageBuffs: [], + effects: [], commonAttackBuffs: [], attackBuffs: [], + damageBuffs: [], }, 'Huang (Mimi)': { id: 'Huang (Mimi)', @@ -798,8 +879,10 @@ export const weaponDefinitions: Data = { charge: 0, }, commonDamageBuffs: [], + effects: [], commonAttackBuffs: ['volt-resonance'], attackBuffs: [], + damageBuffs: [], }, Huma: { id: 'Huma', @@ -824,8 +907,10 @@ export const weaponDefinitions: Data = { charge: 0, }, commonDamageBuffs: [], + effects: [], commonAttackBuffs: [], attackBuffs: [], + damageBuffs: [], }, Icarus: { id: 'Icarus', @@ -863,8 +948,10 @@ export const weaponDefinitions: Data = { charge: 0, }, commonDamageBuffs: [], + effects: [], commonAttackBuffs: [], attackBuffs: [], + damageBuffs: [], }, King: { id: 'King', @@ -889,8 +976,10 @@ export const weaponDefinitions: Data = { charge: 0, }, commonDamageBuffs: [], + effects: [], commonAttackBuffs: [], attackBuffs: [], + damageBuffs: [], }, Lan: { id: 'Lan', @@ -928,8 +1017,10 @@ export const weaponDefinitions: Data = { charge: 0, }, commonDamageBuffs: [], + effects: [], commonAttackBuffs: [], attackBuffs: [], + damageBuffs: [], }, Lin: { id: 'Lin', @@ -1002,8 +1093,10 @@ export const weaponDefinitions: Data = { charge: 0, }, commonDamageBuffs: [], + effects: [], commonAttackBuffs: [], attackBuffs: [], + damageBuffs: [], }, 'Ling Han': { id: 'Ling Han', @@ -1052,8 +1145,10 @@ export const weaponDefinitions: Data = { charge: 0, }, commonDamageBuffs: [], + effects: [], commonAttackBuffs: [], attackBuffs: [], + damageBuffs: [], }, 'Liu Huo': { id: 'Liu Huo', @@ -1091,8 +1186,10 @@ export const weaponDefinitions: Data = { charge: 0, }, commonDamageBuffs: [], + effects: [], commonAttackBuffs: [], attackBuffs: [], + damageBuffs: [], }, Lyra: { id: 'Lyra', @@ -1144,8 +1241,10 @@ export const weaponDefinitions: Data = { charge: 0, }, commonDamageBuffs: [], + effects: [], commonAttackBuffs: [], attackBuffs: [], + damageBuffs: [], }, Meryl: { id: 'Meryl', @@ -1170,8 +1269,10 @@ export const weaponDefinitions: Data = { charge: 0, }, commonDamageBuffs: [], + effects: [], commonAttackBuffs: [], attackBuffs: [], + damageBuffs: [], }, 'Ming Jing': { id: 'Ming Jing', @@ -1234,8 +1335,10 @@ export const weaponDefinitions: Data = { charge: 0, }, commonDamageBuffs: [], + effects: [], commonAttackBuffs: [], attackBuffs: [], + damageBuffs: [], }, 'Nan Yin': { id: 'Nan Yin', @@ -1293,6 +1396,7 @@ export const weaponDefinitions: Data = { cooldown: 30000, charge: 0, }, + effects: [], commonAttackBuffs: [], commonDamageBuffs: [], attackBuffs: [ @@ -1318,6 +1422,7 @@ export const weaponDefinitions: Data = { }, }, ], + damageBuffs: [], }, Nemesis: { id: 'Nemesis', @@ -1390,8 +1495,10 @@ export const weaponDefinitions: Data = { charge: 0, }, commonDamageBuffs: [], + effects: [], commonAttackBuffs: [], attackBuffs: [], + damageBuffs: [], }, Plotti: { id: 'Plotti', @@ -1442,8 +1549,10 @@ export const weaponDefinitions: Data = { charge: 0, }, commonDamageBuffs: [], + effects: [], commonAttackBuffs: [], attackBuffs: [], + damageBuffs: [], }, Rubilia: { id: 'Rubilia', @@ -1481,8 +1590,10 @@ export const weaponDefinitions: Data = { charge: 0, }, commonDamageBuffs: [], + effects: [], commonAttackBuffs: [], attackBuffs: [], + damageBuffs: [], }, Ruby: { id: 'Ruby', @@ -1543,8 +1654,10 @@ export const weaponDefinitions: Data = { charge: 0, }, commonDamageBuffs: [], + effects: [], commonAttackBuffs: [], attackBuffs: [], + damageBuffs: [], }, 'Saki Fuwa': { id: 'Saki Fuwa', @@ -1582,8 +1695,10 @@ export const weaponDefinitions: Data = { charge: 0, }, commonDamageBuffs: [], + effects: [], commonAttackBuffs: [], attackBuffs: [], + damageBuffs: [], }, Samir: { id: 'Samir', @@ -1608,8 +1723,10 @@ export const weaponDefinitions: Data = { charge: 0, }, commonDamageBuffs: [], + effects: [], commonAttackBuffs: [], attackBuffs: [], + damageBuffs: [], }, Shiro: { id: 'Shiro', @@ -1634,8 +1751,10 @@ export const weaponDefinitions: Data = { charge: 0, }, commonDamageBuffs: [], + effects: [], commonAttackBuffs: [], attackBuffs: [], + damageBuffs: [], }, 'Tian Lang': { id: 'Tian Lang', @@ -1686,8 +1805,10 @@ export const weaponDefinitions: Data = { charge: 0, }, commonDamageBuffs: [], + effects: [], commonAttackBuffs: [], attackBuffs: [], + damageBuffs: [], }, Tsubasa: { id: 'Tsubasa', @@ -1712,8 +1833,10 @@ export const weaponDefinitions: Data = { charge: 0, }, commonDamageBuffs: [], + effects: [], commonAttackBuffs: [], attackBuffs: [], + damageBuffs: [], }, Umi: { id: 'Umi', @@ -1764,8 +1887,10 @@ export const weaponDefinitions: Data = { charge: 0, }, commonDamageBuffs: [], + effects: [], commonAttackBuffs: [], attackBuffs: [], + damageBuffs: [], }, 'Yan Miao': { id: 'Yan Miao', @@ -1828,8 +1953,10 @@ export const weaponDefinitions: Data = { charge: 0, }, commonDamageBuffs: [], + effects: [], commonAttackBuffs: [], attackBuffs: [], + damageBuffs: [], }, Yanuo: { id: 'Yanuo', @@ -1903,9 +2030,11 @@ export const weaponDefinitions: Data = { cooldown: 30000, charge: 0, }, + effects: [], commonAttackBuffs: ['frost-resonance', 'volt-resonance'], commonDamageBuffs: [], attackBuffs: [], + damageBuffs: [], }, 'Yu Lan': { id: 'Yu Lan', @@ -1943,8 +2072,10 @@ export const weaponDefinitions: Data = { charge: 0, }, commonDamageBuffs: [], + effects: [], commonAttackBuffs: [], attackBuffs: [], + damageBuffs: [], }, Zero: { id: 'Zero', @@ -1995,8 +2126,10 @@ export const weaponDefinitions: Data = { charge: 0, }, commonDamageBuffs: [], + effects: [], commonAttackBuffs: [], attackBuffs: [], + damageBuffs: [], }, }, }; diff --git a/src/models/v4/__tests__/__snapshots__/combat-simulator.test.ts.snap b/src/models/v4/__tests__/__snapshots__/combat-simulator.test.ts.snap index 7089c67c..38310760 100644 --- a/src/models/v4/__tests__/__snapshots__/combat-simulator.test.ts.snap +++ b/src/models/v4/__tests__/__snapshots__/combat-simulator.test.ts.snap @@ -59,6 +59,50 @@ exports[`CombatSimulator available attacks includes attacks from all weapons at "force-impact", ], "critRateBuffs": [], + "damageBuffs": [ + { + "cooldown": 0, + "damageCategory": "[TEMP_UNKNOWN]", + "description": "During Pact Amplification, when Pactcrest ☆ Metz is in the main slot, increase the Wanderer's volt damage by 25%.", + "displayName": "Brevey - Pact Amplification Volt Buff", + "duration": { + "followActiveWeapon": true, + }, + "elementalTypes": [ + "Volt", + ], + "id": "brevey-damage-buff-pact-amplification-volt", + "maxStacks": 1, + "requirements": { + "activeEffect": "brevey-effect-pact-amplification", + }, + "triggeredBy": { + "activeWeapon": "Brevey", + }, + "value": 0.25, + }, + { + "cooldown": 0, + "damageCategory": "[TEMP_UNKNOWN]", + "description": "During Pact Amplification, when Pactcrest ☆ Metz is in the main slot, increase the Wanderer's frost damage by 10%.", + "displayName": "Brevey - Pact Amplification Frost Buff", + "duration": { + "followActiveWeapon": true, + }, + "elementalTypes": [ + "Frost", + ], + "id": "brevey-damage-buff-pact-amplification-frost", + "maxStacks": 1, + "requirements": { + "activeEffect": "brevey-effect-pact-amplification", + }, + "triggeredBy": { + "notActiveWeapon": "Brevey", + }, + "value": 0.25, + }, + ], "damageElement": "Volt", "discharge": { "attackFlat": 0, @@ -85,6 +129,23 @@ exports[`CombatSimulator available attacks includes attacks from all weapons at "type": "dodge", }, ], + "effects": [ + { + "cooldown": 30000, + "description": "", + "displayName": "Brevey - Pact Amplification", + "duration": { + "value": 30000, + }, + "id": "brevey-effect-pact-amplification", + "maxStacks": 1, + "triggeredBy": { + "weaponAttacks": [ + "brevey-skill-million-metz-shockwave", + ], + }, + }, + ], "id": "Brevey", "normalAttacks": [ { @@ -179,6 +240,50 @@ exports[`CombatSimulator available attacks includes attacks from all weapons at "force-impact", ], "critRateBuffs": [], + "damageBuffs": [ + { + "cooldown": 0, + "damageCategory": "[TEMP_UNKNOWN]", + "description": "During Pact Amplification, when Pactcrest ☆ Metz is in the main slot, increase the Wanderer's volt damage by 25%.", + "displayName": "Brevey - Pact Amplification Volt Buff", + "duration": { + "followActiveWeapon": true, + }, + "elementalTypes": [ + "Volt", + ], + "id": "brevey-damage-buff-pact-amplification-volt", + "maxStacks": 1, + "requirements": { + "activeEffect": "brevey-effect-pact-amplification", + }, + "triggeredBy": { + "activeWeapon": "Brevey", + }, + "value": 0.25, + }, + { + "cooldown": 0, + "damageCategory": "[TEMP_UNKNOWN]", + "description": "During Pact Amplification, when Pactcrest ☆ Metz is in the main slot, increase the Wanderer's frost damage by 10%.", + "displayName": "Brevey - Pact Amplification Frost Buff", + "duration": { + "followActiveWeapon": true, + }, + "elementalTypes": [ + "Frost", + ], + "id": "brevey-damage-buff-pact-amplification-frost", + "maxStacks": 1, + "requirements": { + "activeEffect": "brevey-effect-pact-amplification", + }, + "triggeredBy": { + "notActiveWeapon": "Brevey", + }, + "value": 0.25, + }, + ], "damageElement": "Volt", "discharge": { "attackFlat": 0, @@ -205,6 +310,23 @@ exports[`CombatSimulator available attacks includes attacks from all weapons at "type": "dodge", }, ], + "effects": [ + { + "cooldown": 30000, + "description": "", + "displayName": "Brevey - Pact Amplification", + "duration": { + "value": 30000, + }, + "id": "brevey-effect-pact-amplification", + "maxStacks": 1, + "triggeredBy": { + "weaponAttacks": [ + "brevey-skill-million-metz-shockwave", + ], + }, + }, + ], "id": "Brevey", "normalAttacks": [ { @@ -299,6 +421,50 @@ exports[`CombatSimulator available attacks includes attacks from all weapons at "force-impact", ], "critRateBuffs": [], + "damageBuffs": [ + { + "cooldown": 0, + "damageCategory": "[TEMP_UNKNOWN]", + "description": "During Pact Amplification, when Pactcrest ☆ Metz is in the main slot, increase the Wanderer's volt damage by 25%.", + "displayName": "Brevey - Pact Amplification Volt Buff", + "duration": { + "followActiveWeapon": true, + }, + "elementalTypes": [ + "Volt", + ], + "id": "brevey-damage-buff-pact-amplification-volt", + "maxStacks": 1, + "requirements": { + "activeEffect": "brevey-effect-pact-amplification", + }, + "triggeredBy": { + "activeWeapon": "Brevey", + }, + "value": 0.25, + }, + { + "cooldown": 0, + "damageCategory": "[TEMP_UNKNOWN]", + "description": "During Pact Amplification, when Pactcrest ☆ Metz is in the main slot, increase the Wanderer's frost damage by 10%.", + "displayName": "Brevey - Pact Amplification Frost Buff", + "duration": { + "followActiveWeapon": true, + }, + "elementalTypes": [ + "Frost", + ], + "id": "brevey-damage-buff-pact-amplification-frost", + "maxStacks": 1, + "requirements": { + "activeEffect": "brevey-effect-pact-amplification", + }, + "triggeredBy": { + "notActiveWeapon": "Brevey", + }, + "value": 0.25, + }, + ], "damageElement": "Volt", "discharge": { "attackFlat": 0, @@ -325,6 +491,23 @@ exports[`CombatSimulator available attacks includes attacks from all weapons at "type": "dodge", }, ], + "effects": [ + { + "cooldown": 30000, + "description": "", + "displayName": "Brevey - Pact Amplification", + "duration": { + "value": 30000, + }, + "id": "brevey-effect-pact-amplification", + "maxStacks": 1, + "triggeredBy": { + "weaponAttacks": [ + "brevey-skill-million-metz-shockwave", + ], + }, + }, + ], "id": "Brevey", "normalAttacks": [ { @@ -417,6 +600,7 @@ exports[`CombatSimulator available attacks includes attacks from all weapons at ], "commonDamageBuffs": [], "critRateBuffs": [], + "damageBuffs": [], "damageElement": "Frost", "discharge": { "attackFlat": 0, @@ -431,6 +615,7 @@ exports[`CombatSimulator available attacks includes attacks from all weapons at }, "displayName": "Yanuo", "dodgeAttacks": [], + "effects": [], "id": "Yanuo", "normalAttacks": [ { @@ -523,6 +708,7 @@ exports[`CombatSimulator available attacks includes attacks from all weapons at ], "commonDamageBuffs": [], "critRateBuffs": [], + "damageBuffs": [], "damageElement": "Frost", "discharge": { "attackFlat": 0, @@ -537,6 +723,7 @@ exports[`CombatSimulator available attacks includes attacks from all weapons at }, "displayName": "Yanuo", "dodgeAttacks": [], + "effects": [], "id": "Yanuo", "normalAttacks": [ { @@ -646,6 +833,7 @@ exports[`CombatSimulator available attacks includes attacks from all weapons at "commonAttackBuffs": [], "commonDamageBuffs": [], "critRateBuffs": [], + "damageBuffs": [], "damageElement": "Altered", "discharge": { "attackFlat": 0, @@ -661,6 +849,7 @@ exports[`CombatSimulator available attacks includes attacks from all weapons at }, "displayName": "Nan Yin", "dodgeAttacks": [], + "effects": [], "id": "Nan Yin", "normalAttacks": [ { @@ -691,10 +880,11 @@ exports[`CombatSimulator available attacks includes attacks from all weapons at exports[`CombatSimulator relic damage buff is added at the start of combat and lasts for the entire combat duration 1`] = ` Map { - "Alternative Destiny passive damage buff" => BuffTimeline { + "Alternative Destiny passive damage buff" => EffectTimeline { "_events": [ - BuffEvent { - "buffDefinition": { + EffectEvent { + "duration": 150000, + "effectDefinition": { "cooldown": 0, "damageCategory": "Relic passive", "description": "Increase frost damage by 2%, even if not deployed.", @@ -714,17 +904,17 @@ Map { }, "value": 0.02, }, - "duration": 150000, "maxStacks": 1, "stacks": 1, "startTime": 0, }, ], }, - "Cybernetic arm passive damage buff" => BuffTimeline { + "Cybernetic arm passive damage buff" => EffectTimeline { "_events": [ - BuffEvent { - "buffDefinition": { + EffectEvent { + "duration": 150000, + "effectDefinition": { "cooldown": 0, "damageCategory": "Relic passive", "description": "Increase frost damage by 1.5%, even if not deployed.", @@ -744,17 +934,17 @@ Map { }, "value": 0.015, }, - "duration": 150000, "maxStacks": 1, "stacks": 1, "startTime": 0, }, ], }, - "Thalassic Heart passive damage buff" => BuffTimeline { + "Thalassic Heart passive damage buff" => EffectTimeline { "_events": [ - BuffEvent { - "buffDefinition": { + EffectEvent { + "duration": 150000, + "effectDefinition": { "cooldown": 0, "damageCategory": "Relic passive", "description": "Increase volt damage by 2%, even if not deployed.", @@ -774,17 +964,17 @@ Map { }, "value": 0.02, }, - "duration": 150000, "maxStacks": 1, "stacks": 1, "startTime": 0, }, ], }, - "Triple Mask damage buff" => BuffTimeline { + "Triple Mask damage buff" => EffectTimeline { "_events": [ - BuffEvent { - "buffDefinition": { + EffectEvent { + "duration": 150000, + "effectDefinition": { "cooldown": 0, "damageCategory": "DMG buff category 1", "description": "While Triple Mask is deployed, elemental damage is increased by 6%.", @@ -808,7 +998,6 @@ Map { }, "value": 0.06, }, - "duration": 150000, "maxStacks": 1, "stacks": 1, "startTime": 0, diff --git a/src/models/v4/__tests__/combat-simulator.test.ts b/src/models/v4/__tests__/combat-simulator.test.ts index c3c97a2d..384ec79a 100644 --- a/src/models/v4/__tests__/combat-simulator.test.ts +++ b/src/models/v4/__tests__/combat-simulator.test.ts @@ -286,6 +286,55 @@ describe('CombatSimulator', () => { }); }); + describe('weapon damage buff', () => { + it('is added based on an active effect, active/not-active weapon', () => { + // Test Brevey's volt buff and frost buff during her Pact Amplification + const sut = new CombatSimulator(combatDuration, loadout, relics); + sut.performAttack({ + weapon: weapon1, + attackDefinition: weapon1.definition.skills[0], + }); + sut.performAttack({ + weapon: weapon1, + attackDefinition: weapon1.definition.normalAttacks[0], + }); + sut.performAttack({ + weapon: weapon1, + attackDefinition: weapon1.definition.normalAttacks[0], + }); + sut.performAttack({ + weapon: weapon2, + attackDefinition: weapon2.definition.normalAttacks[0], + }); + + const voltBuffEvent = sut.weaponDamageBuffTimelines.get( + 'brevey-damage-buff-pact-amplification-volt' + )?.lastEvent; + expect(voltBuffEvent).toBeDefined(); + if (voltBuffEvent) { + expect(voltBuffEvent.startTime).toBe( + sut.attackTimelines.get(weapon1)?.events[1].startTime + ); + expect(voltBuffEvent.endTime).toBe( + sut.attackTimelines.get(weapon1)?.events[2].endTime + ); + } + + const frostBuffEvent = sut.weaponDamageBuffTimelines.get( + 'brevey-damage-buff-pact-amplification-frost' + )?.lastEvent; + expect(frostBuffEvent).toBeDefined(); + if (frostBuffEvent) { + expect(frostBuffEvent.startTime).toBe( + sut.attackTimelines.get(weapon2)?.lastEvent?.startTime + ); + expect(frostBuffEvent.endTime).toBe( + sut.attackTimelines.get(weapon2)?.lastEvent?.endTime + ); + } + }); + }); + describe('relic damage buff', () => { it('is added at the start of combat and lasts for the entire combat duration', () => { relics.setRelicStars('Cybernetic Arm', 4); // Frost +1.5% @@ -1057,4 +1106,25 @@ describe('CombatSimulator', () => { ); }); }); + + describe('weapon effect', () => { + it('is added with a duration, triggered by an attack', () => { + const sut = new CombatSimulator(combatDuration, loadout, relics); + sut.performAttack({ + weapon: weapon1, + attackDefinition: weapon1.definition.skills[0], + }); + + const effectEvent = sut.weaponEffectsTimelines.get( + 'brevey-effect-pact-amplification' + )?.lastEvent; + expect(effectEvent).toBeDefined(); + if (effectEvent) { + expect(effectEvent.startTime).toBe( + sut.attackTimelines.get(weapon1)?.lastEvent?.startTime + ); + expect(effectEvent.duration).toBe(30000); + } + }); + }); }); diff --git a/src/models/v4/buffs/attack-buff-definition.ts b/src/models/v4/buffs/attack-buff-definition.ts index 46f8d221..ce9aad6e 100644 --- a/src/models/v4/buffs/attack-buff-definition.ts +++ b/src/models/v4/buffs/attack-buff-definition.ts @@ -1,7 +1,7 @@ import type { WeaponElementalType } from '../../../constants/elemental-type'; -import type { BuffDefinition } from './buff-definition'; +import type { EffectDefinition } from '../effect-definition'; -export interface AttackBuffDefinition extends BuffDefinition { +export interface AttackBuffDefinition extends EffectDefinition { value: number; elementalTypes: WeaponElementalType[]; } diff --git a/src/models/v4/buffs/damage-buff-definition.ts b/src/models/v4/buffs/damage-buff-definition.ts index 8d0ea122..c91dd8fa 100644 --- a/src/models/v4/buffs/damage-buff-definition.ts +++ b/src/models/v4/buffs/damage-buff-definition.ts @@ -1,7 +1,7 @@ import type { WeaponElementalType } from '../../../constants/elemental-type'; -import type { BuffDefinition } from './buff-definition'; +import type { EffectDefinition } from '../effect-definition'; -export interface DamageBuffDefinition extends BuffDefinition { +export interface DamageBuffDefinition extends EffectDefinition { value: number; /** The elemental types the damage buff applies to */ elementalTypes: WeaponElementalType[]; diff --git a/src/models/v4/buffs/miscellaneous-buff-definition.ts b/src/models/v4/buffs/miscellaneous-buff-definition.ts index e037523e..bc944cdd 100644 --- a/src/models/v4/buffs/miscellaneous-buff-definition.ts +++ b/src/models/v4/buffs/miscellaneous-buff-definition.ts @@ -1,8 +1,8 @@ import type { WeaponElementalType } from '../../../constants/elemental-type'; import type { WeaponName } from '../../../constants/weapon-definitions'; -import type { BuffDefinition } from './buff-definition'; +import type { EffectDefinition } from '../effect-definition'; -export interface MiscellaneousBuffDefinition extends BuffDefinition { +export interface MiscellaneousBuffDefinition extends EffectDefinition { // Order buffs from most specific to least specific. Check in this order for efficiency /** Buffs all normal attacks of weapon by value, e.g. value = 0.5 = +50% */ diff --git a/src/models/v4/combat-simulator.ts b/src/models/v4/combat-simulator.ts index 379b3126..9f5b8061 100644 --- a/src/models/v4/combat-simulator.ts +++ b/src/models/v4/combat-simulator.ts @@ -6,34 +6,35 @@ import { commonWeaponDamageBuffs } from '../../constants/common-weapon-damage-bu import type { Loadout } from '../loadout'; import type { Weapon } from '../weapon'; import type { Attack } from './attack'; -import type { BuffDefinition } from './buffs/buff-definition'; +import type { EffectDefinition } from './effect-definition'; import type { Relics } from './relics'; import { AttackEvent } from './timeline/attack-event'; import { AttackTimeline } from './timeline/attack-timeline'; -import { BuffEvent } from './timeline/buff-event'; -import { BuffTimeline } from './timeline/buff-timeline'; import { ChargeTimeline } from './timeline/charge-timeline'; +import { EffectEvent } from './timeline/effect-event'; +import { EffectTimeline } from './timeline/effect-timeline'; export class CombatSimulator { public readonly attackTimelines = new Map(); - public readonly weaponAttackBuffTimelines = new Map(); - public readonly weaponDamageBuffTimelines = new Map(); - public readonly relicDamageBuffTimelines = new Map(); - public readonly traitAttackBuffTimelines = new Map(); - public readonly traitDamageBuffTimelines = new Map(); - public readonly traitMiscBuffTimelines = new Map(); + public readonly weaponAttackBuffTimelines = new Map(); + public readonly weaponDamageBuffTimelines = new Map(); + public readonly relicDamageBuffTimelines = new Map(); + public readonly traitAttackBuffTimelines = new Map(); + public readonly traitDamageBuffTimelines = new Map(); + public readonly traitMiscBuffTimelines = new Map(); public readonly chargeTimeline = new ChargeTimeline(); + public readonly weaponEffectsTimelines = new Map(); private _activeWeapon: Weapon | undefined; /** The previous weapon before switching to the current active weapon */ private _previousWeapon: Weapon | undefined; - /** Registered buff definitions will be checked whenever an attack happens. A buff event will be added to the timeline if the conditions defined in the buff definition are met */ - private registeredBuffs: { - buffDefinitions: BuffDefinition[]; - timelineGroupToAddTo: Map; + /** Registered effect definitions will be checked whenever an attack happens. An effect event will be added to the specified timeline if the conditions defined in the effect definition are met */ + private registeredEffects: { + effectDefinitions: EffectDefinition[]; + timelineGroupToAddTo: Map; }[] = []; public constructor( @@ -45,7 +46,7 @@ export class CombatSimulator { this.attackTimelines.set(weapon, new AttackTimeline()); }); - this.registerBuffs(); + this.registerEffects(); } public get activeWeapon(): Weapon | undefined { @@ -141,13 +142,14 @@ export class CombatSimulator { attackTimeline.addEvent(attackEvent); - // Register all buffs first at the start of combat + // Register all events first at the start of combat if (attackEvent.startTime === 0) { - this.registerBuffs(); + this.registerEffects(); } this.adjustCharge(attackEvent); - this.triggerRegisteredBuffsIfApplicable(attackEvent); + + this.triggerRegisteredEffectsIfApplicable(attackEvent); } private adjustCharge(attackEvent: AttackEvent) { @@ -161,12 +163,12 @@ export class CombatSimulator { } } - private triggerRegisteredBuffsIfApplicable(attackEvent: AttackEvent) { - this.registeredBuffs.forEach( - ({ buffDefinitions, timelineGroupToAddTo }) => { - buffDefinitions.forEach((buffDefinition) => { - this.addBuffIfApplicable( - buffDefinition, + private triggerRegisteredEffectsIfApplicable(attackEvent: AttackEvent) { + this.registeredEffects.forEach( + ({ effectDefinitions, timelineGroupToAddTo }) => { + effectDefinitions.forEach((effectDefinition) => { + this.addEffectIfApplicable( + effectDefinition, timelineGroupToAddTo, attackEvent ); @@ -175,7 +177,7 @@ export class CombatSimulator { ); } - private registerBuffs() { + private registerEffects() { const { loadout: { team: { weapons }, @@ -183,9 +185,27 @@ export class CombatSimulator { }, relics, } = this; - this.registeredBuffs = [ + + // NOTE: Add these in least likely to be depended on from another effect to most likely. This pretty much means order these in most broad to most specific buffs. This is to avoid an effect that is supposed to depend on another to be triggered at the exact same time. This can be improved upon in the future (perhaps add "ticks"?) but it'll do for now (right now a "tick" = when a new attack occurs) + this.registeredEffects = [ + { + effectDefinitions: relics.passiveRelicBuffs, + timelineGroupToAddTo: this.relicDamageBuffTimelines, + }, { - buffDefinitions: weapons.flatMap((weapon) => + effectDefinitions: simulacrumTrait?.attackBuffs ?? [], + timelineGroupToAddTo: this.traitAttackBuffTimelines, + }, + { + effectDefinitions: simulacrumTrait?.damageBuffs ?? [], + timelineGroupToAddTo: this.traitDamageBuffTimelines, + }, + { + effectDefinitions: simulacrumTrait?.miscellaneousBuffs ?? [], + timelineGroupToAddTo: this.traitMiscBuffTimelines, + }, + { + effectDefinitions: weapons.flatMap((weapon) => weapon.definition.commonAttackBuffs.map( (buffId) => commonWeaponAttackBuffs[buffId] ) @@ -193,7 +213,7 @@ export class CombatSimulator { timelineGroupToAddTo: this.weaponAttackBuffTimelines, }, { - buffDefinitions: weapons.flatMap((weapon) => + effectDefinitions: weapons.flatMap((weapon) => weapon.definition.commonDamageBuffs.map( (buffId) => commonWeaponDamageBuffs[buffId] ) @@ -201,64 +221,64 @@ export class CombatSimulator { timelineGroupToAddTo: this.weaponDamageBuffTimelines, }, { - buffDefinitions: weapons.flatMap( + effectDefinitions: weapons.flatMap( (weapon) => weapon.definition.attackBuffs ), timelineGroupToAddTo: this.weaponAttackBuffTimelines, }, { - buffDefinitions: relics.passiveRelicBuffs, - timelineGroupToAddTo: this.relicDamageBuffTimelines, - }, - { - buffDefinitions: simulacrumTrait?.attackBuffs ?? [], - timelineGroupToAddTo: this.traitAttackBuffTimelines, - }, - { - buffDefinitions: simulacrumTrait?.damageBuffs ?? [], - timelineGroupToAddTo: this.traitDamageBuffTimelines, + effectDefinitions: weapons.flatMap( + (weapon) => weapon.definition.damageBuffs + ), + timelineGroupToAddTo: this.weaponDamageBuffTimelines, }, { - buffDefinitions: simulacrumTrait?.miscellaneousBuffs ?? [], - timelineGroupToAddTo: this.traitMiscBuffTimelines, + effectDefinitions: weapons.flatMap( + (weapon) => weapon.definition.effects + ), + timelineGroupToAddTo: this.weaponEffectsTimelines, }, ]; } - private addBuffIfApplicable( - buffDefinition: BuffDefinition, - timelineGroup: Map, + private addEffectIfApplicable( + effectDefinition: EffectDefinition, + timelineGroup: Map, attackEvent: AttackEvent ) { - if (!this.hasBuffMetRequirements(buffDefinition)) return; - if (!this.shouldTriggerBuff(buffDefinition, attackEvent)) return; + if (!this.hasEffectMetRequirements(effectDefinition, attackEvent.startTime)) + return; + if (!this.shouldTriggerEffect(effectDefinition, attackEvent)) return; - const buffTimePeriod = this.determineBuffTimePeriod( - buffDefinition, + const effectTimePeriod = this.determineEffectTimePeriod( + effectDefinition, attackEvent ); - if (!buffTimePeriod) return; + if (!effectTimePeriod) return; - const { id, maxStacks } = buffDefinition; + const { id, maxStacks } = effectDefinition; if (!timelineGroup.has(id)) { - timelineGroup.set(id, new BuffTimeline()); + timelineGroup.set(id, new EffectTimeline()); } timelineGroup .get(id) ?.addEvent( - new BuffEvent( - buffTimePeriod.startTime, - buffTimePeriod.duration, - buffDefinition, + new EffectEvent( + effectTimePeriod.startTime, + effectTimePeriod.duration, + effectDefinition, maxStacks ) ); } - private hasBuffMetRequirements(buff: BuffDefinition): boolean { - const { requirements } = buff; + private hasEffectMetRequirements( + effect: EffectDefinition, + time: number + ): boolean { + const { requirements } = effect; if (!requirements) return true; const { weapons, weaponNames, weaponResonance, weaponElementalTypes } = @@ -266,6 +286,12 @@ export class CombatSimulator { // Check requirements from most specific to least specific for efficiency + if ( + requirements.activeEffect && + !this.isEffectActive(requirements.activeEffect, time) + ) + return false; + if ( requirements.weaponInTeam && !weaponNames.includes(requirements.weaponInTeam) @@ -324,12 +350,12 @@ export class CombatSimulator { return true; } - private shouldTriggerBuff( - buffDefinition: BuffDefinition, + private shouldTriggerEffect( + effectDefinition: EffectDefinition, attackEvent: AttackEvent ): boolean { // TODO: cooldown - const { triggeredBy } = buffDefinition; + const { triggeredBy } = effectDefinition; const { attackDefinition: { id: attackId, type: attackType }, @@ -381,6 +407,9 @@ export class CombatSimulator { ) return true; + if (triggeredBy.notActiveWeapon && weaponId !== triggeredBy.notActiveWeapon) + return true; + if (triggeredBy.activeWeapon && weaponId === triggeredBy.activeWeapon) return true; @@ -393,8 +422,8 @@ export class CombatSimulator { return false; } - private determineBuffTimePeriod( - buff: BuffDefinition, + private determineEffectTimePeriod( + effect: EffectDefinition, attackEvent: AttackEvent ): { startTime: number; duration: number } | undefined { const { @@ -404,7 +433,7 @@ export class CombatSimulator { applyToEndSegmentOfCombat, untilCombatEnd, }, - } = buff; + } = effect; if (value) { return { startTime: attackEvent.startTime, duration: value }; @@ -436,4 +465,17 @@ export class CombatSimulator { return undefined; } + + /** Check if an effect at the given time by checking through the registered effects */ + private isEffectActive(effectId: string, time: number) { + // Assume there will only be one timeline holding the effect, not multiple + for (const { timelineGroupToAddTo } of this.registeredEffects) { + const eventTimeline = timelineGroupToAddTo.get(effectId); + if (eventTimeline) { + return eventTimeline.getEventsOverlapping(time, time).length !== 0; + } + } + + return false; + } } diff --git a/src/models/v4/buffs/buff-definition.ts b/src/models/v4/effect-definition.ts similarity index 66% rename from src/models/v4/buffs/buff-definition.ts rename to src/models/v4/effect-definition.ts index 39c786e9..c2bd1d7b 100644 --- a/src/models/v4/buffs/buff-definition.ts +++ b/src/models/v4/effect-definition.ts @@ -1,11 +1,11 @@ -import type { WeaponElementalType } from '../../../constants/elemental-type'; +import type { WeaponElementalType } from '../../constants/elemental-type'; import type { WeaponName, WeaponType, -} from '../../../constants/weapon-definitions'; -import type { WeaponResonance } from '../../../constants/weapon-resonance'; +} from '../../constants/weapon-definitions'; +import type { WeaponResonance } from '../../constants/weapon-resonance'; -export interface BuffDefinition { +export interface EffectDefinition { id: string; displayName: string; description: string; @@ -21,24 +21,27 @@ export interface BuffDefinition { skillOfElementalType?: WeaponElementalType; dischargeOfElementalType?: WeaponElementalType; fullChargeOfWeapons?: WeaponName[]; + /** e.g. If [weapon] is in off-hand slot, ... */ + notActiveWeapon?: WeaponName; activeWeapon?: WeaponName; weaponAttacks?: string[]; }; duration: { value?: number; - /** Buff ends when active weapon changes */ + /** Effect ends when active weapon changes */ followActiveWeapon?: boolean; - /** Number between 0 to 1. e.g. 0.7 = buff only applies to 0.7 of the combat duration at the end. The starting 30% has no buffs. Useful for buffs like "increase damage dealt to targets with less than x% HP" */ + /** Number between 0 to 1. e.g. 0.7 = effect only applies to 0.7 of the combat duration at the end. The starting 30% has no effect. Useful for effects like "increase damage dealt to targets with less than x% HP" */ applyToEndSegmentOfCombat?: number; - /** Buff lasts until combat ends */ + /** Effect lasts until combat ends */ untilCombatEnd?: boolean; }; - /** Buff goes into cooldown when triggered and cannot be triggered again until cooldown ends */ + /** Effect goes into cooldown when triggered and cannot be triggered again until cooldown ends */ cooldown: number; // Order requirements from most specific to least specific. Check in this order for efficiency requirements?: { + activeEffect?: string; weaponInTeam?: WeaponName; weaponResonance?: WeaponResonance; elementalTypeWeaponsInTeam?: { diff --git a/src/models/v4/timeline/__tests__/buff-timeline.test.ts b/src/models/v4/timeline/__tests__/effect-timeline.test.ts similarity index 51% rename from src/models/v4/timeline/__tests__/buff-timeline.test.ts rename to src/models/v4/timeline/__tests__/effect-timeline.test.ts index 70a95057..a967913b 100644 --- a/src/models/v4/timeline/__tests__/buff-timeline.test.ts +++ b/src/models/v4/timeline/__tests__/effect-timeline.test.ts @@ -1,15 +1,15 @@ -import type { BuffDefinition } from '../../buffs/buff-definition'; -import { BuffEvent } from '../buff-event'; -import { BuffTimeline } from '../buff-timeline'; +import type { EffectDefinition } from '../../effect-definition'; +import { EffectEvent } from '../effect-event'; +import { EffectTimeline } from '../effect-timeline'; -describe('Buff timeline', () => { - const mockBuffDefinition = {} as BuffDefinition; +describe('Effect timeline', () => { + const mockEffectDefinition = {} as EffectDefinition; describe('adding an event that overlaps with an existing one', () => { it('splits the events into smaller events with the correct stacks', () => { - const sut = new BuffTimeline(); - sut.addEvent(new BuffEvent(0, 10, mockBuffDefinition, 2)); - sut.addEvent(new BuffEvent(5, 10, mockBuffDefinition, 2)); + const sut = new EffectTimeline(); + sut.addEvent(new EffectEvent(0, 10, mockEffectDefinition, 2)); + sut.addEvent(new EffectEvent(5, 10, mockEffectDefinition, 2)); expect(sut.events.length).toBe(3); @@ -30,18 +30,18 @@ describe('Buff timeline', () => { }); it('merges the two events by increasing the existing the duration of the existing event, if the resulting stacks of the two events are the same', () => { - const sut = new BuffTimeline(); - sut.addEvent(new BuffEvent(0, 10, mockBuffDefinition, 1)); - sut.addEvent(new BuffEvent(5, 10, mockBuffDefinition, 1)); + const sut = new EffectTimeline(); + sut.addEvent(new EffectEvent(0, 10, mockEffectDefinition, 1)); + sut.addEvent(new EffectEvent(5, 10, mockEffectDefinition, 1)); expect(sut.events.length).toBe(1); expect(sut.events[0].endTime).toBe(15); }); it("doesn't add a new event if the two events are of the exact same time period and the stack count cannot be increased further", () => { - const sut = new BuffTimeline(); - sut.addEvent(new BuffEvent(0, 10, mockBuffDefinition, 1)); - sut.addEvent(new BuffEvent(0, 10, mockBuffDefinition, 1)); + const sut = new EffectTimeline(); + sut.addEvent(new EffectEvent(0, 10, mockEffectDefinition, 1)); + sut.addEvent(new EffectEvent(0, 10, mockEffectDefinition, 1)); expect(sut.events.length).toBe(1); expect(sut.events[0].startTime).toBe(0); @@ -50,9 +50,9 @@ describe('Buff timeline', () => { }); it("doesn't add a new event if the two events are of the exact same time period, but increase the stack count of the existing event if it can be increased further", () => { - const sut = new BuffTimeline(); - sut.addEvent(new BuffEvent(0, 10, mockBuffDefinition, 3, 1)); - sut.addEvent(new BuffEvent(0, 10, mockBuffDefinition, 3, 2)); + const sut = new EffectTimeline(); + sut.addEvent(new EffectEvent(0, 10, mockEffectDefinition, 3, 1)); + sut.addEvent(new EffectEvent(0, 10, mockEffectDefinition, 3, 2)); expect(sut.events.length).toBe(1); expect(sut.events[0].startTime).toBe(0); @@ -62,18 +62,28 @@ describe('Buff timeline', () => { }); it('adds a new event when there are no previous events', () => { - const sut = new BuffTimeline(); - sut.addEvent(new BuffEvent(0, 10, mockBuffDefinition)); + const sut = new EffectTimeline(); + sut.addEvent(new EffectEvent(0, 10, mockEffectDefinition)); expect(sut.events.length).toBe(1); expect(sut.events[0].startTime).toBe(0); expect(sut.events[0].endTime).toBe(10); }); - it("adds a new event when it doesn't overlap with the last event", () => { - const sut = new BuffTimeline(); - sut.addEvent(new BuffEvent(0, 10, mockBuffDefinition)); - sut.addEvent(new BuffEvent(10, 10, mockBuffDefinition)); + it('adds a new event onto the previous event (merges them) when the new event starts when the previous event ends and the two have the same number of stacks', () => { + const sut = new EffectTimeline(); + sut.addEvent(new EffectEvent(0, 10, mockEffectDefinition, 2, 1)); + sut.addEvent(new EffectEvent(10, 10, mockEffectDefinition, 2, 1)); + + expect(sut.events.length).toBe(1); + expect(sut.events[0].startTime).toBe(0); + expect(sut.events[0].endTime).toBe(20); + }); + + it('adds a new event when the new event starts when the previous event ends but the two do not have the same number of stacks', () => { + const sut = new EffectTimeline(); + sut.addEvent(new EffectEvent(0, 10, mockEffectDefinition, 2, 1)); + sut.addEvent(new EffectEvent(10, 10, mockEffectDefinition, 2, 2)); expect(sut.events.length).toBe(2); expect(sut.events[1].startTime).toBe(10); diff --git a/src/models/v4/timeline/__tests__/timeline.test.ts b/src/models/v4/timeline/__tests__/timeline.test.ts index 2f503351..ca1d8f55 100644 --- a/src/models/v4/timeline/__tests__/timeline.test.ts +++ b/src/models/v4/timeline/__tests__/timeline.test.ts @@ -12,6 +12,9 @@ describe('Timeline', () => { const event4 = new TimelineEvent(9, 14); // in const event5 = new TimelineEvent(11, 16); // out const event6 = new TimelineEvent(12, 17); // out + const event7 = new TimelineEvent(0, 20); // in + const event8 = new TimelineEvent(6, 0); // in + const event9 = new TimelineEvent(11, 0); // out sut.addEvent(event1); sut.addEvent(event2); @@ -19,6 +22,9 @@ describe('Timeline', () => { sut.addEvent(event4); sut.addEvent(event5); sut.addEvent(event6); + sut.addEvent(event7); + sut.addEvent(event8); + sut.addEvent(event9); const overlappingEvents = sut.getEventsOverlapping(6, 11); @@ -28,5 +34,8 @@ describe('Timeline', () => { expect(overlappingEvents).toContain(event4); expect(overlappingEvents).not.toContain(event5); expect(overlappingEvents).not.toContain(event6); + expect(overlappingEvents).toContain(event7); + expect(overlappingEvents).toContain(event8); + expect(overlappingEvents).not.toContain(event9); }); }); diff --git a/src/models/v4/timeline/buff-event.ts b/src/models/v4/timeline/effect-event.ts similarity index 58% rename from src/models/v4/timeline/buff-event.ts rename to src/models/v4/timeline/effect-event.ts index 47eb5d01..b2a40f49 100644 --- a/src/models/v4/timeline/buff-event.ts +++ b/src/models/v4/timeline/effect-event.ts @@ -1,11 +1,11 @@ -import type { BuffDefinition } from '../buffs/buff-definition'; +import type { EffectDefinition } from '../effect-definition'; import { TimelineEvent } from './timeline-event'; -export class BuffEvent extends TimelineEvent { +export class EffectEvent extends TimelineEvent { public constructor( public startTime: number, public duration: number, - public buffDefinition: BuffDefinition, + public effectDefinition: EffectDefinition, public maxStacks: number = 1, public stacks: number = 1 ) { @@ -13,6 +13,6 @@ export class BuffEvent extends TimelineEvent { } public get displayName(): string { - return this.buffDefinition.displayName; + return this.effectDefinition.displayName; } } diff --git a/src/models/v4/timeline/buff-timeline.ts b/src/models/v4/timeline/effect-timeline.ts similarity index 63% rename from src/models/v4/timeline/buff-timeline.ts rename to src/models/v4/timeline/effect-timeline.ts index c9d09dd5..3cc945ee 100644 --- a/src/models/v4/timeline/buff-timeline.ts +++ b/src/models/v4/timeline/effect-timeline.ts @@ -1,20 +1,30 @@ -import { BuffEvent } from './buff-event'; +import { EffectEvent } from './effect-event'; import { Timeline } from './timeline'; -/** Buff timeline where events can have "stacks" e.g. a damage buff could have 2 stacks of "+10%". Events will still be linear/chronological, however. Adding an event that overlaps with an existing event will increase the "stack" over the overlapping time period, if possible. Assuming all events are the same "type", and are all stackable with each other */ -export class BuffTimeline extends Timeline { +/** Effect timeline where events can have "stacks" e.g. a damage buff event could have 2 stacks of "+10%". Events will still be linear/chronological, however. Adding an event that overlaps with an existing event will increase the "stack" over the overlapping time period, if possible. Assuming all events are the same "type", and are all stackable with each other */ +export class EffectTimeline extends Timeline { /** Adds an event to the end of the timeline. The new event can overlap with the last existing event in the timeline, but cannot be before it. * * If there is an overlapping existing event, increase its stack count by the number of stacks in the new event, up to the max stack count. If the resulting stack count of the existing event is the same as the new event, simply "merge" the two events by increasing the duration of the existing event. */ - public addEvent(event: BuffEvent) { + public addEvent(event: EffectEvent) { const { lastEvent } = this; - // Event does not overlap with an existing one, add new event as usual - if (!lastEvent || event.startTime >= lastEvent.endTime) { + // Event does not overlap with an existing one whatsoever, add new event as usual + if (!lastEvent || event.startTime > lastEvent.endTime) { super.addEvent(event); return; } + // Event starts when the previous one ends - Merge the two of they have the same number of stacks, or add a new one if not + if (event.startTime === lastEvent.endTime) { + if (event.stacks === lastEvent.stacks) { + lastEvent.endTime = event.endTime; + } else { + super.addEvent(event); + } + return; + } + if (event.startTime < lastEvent.startTime) { throw new Error( 'Cannot add an event that is earlier than the latest event' @@ -50,10 +60,10 @@ export class BuffTimeline extends Timeline { } if (newStacksOfOverlappingPeriod === lastEvent.stacks) { - const newEvent = new BuffEvent( + const newEvent = new EffectEvent( lastEvent.endTime, event.duration, - event.buffDefinition, + event.effectDefinition, event.maxStacks, event.stacks ); @@ -65,19 +75,19 @@ export class BuffTimeline extends Timeline { lastEvent.endTime = event.startTime; - const newEventOfOverlappingPeriod = new BuffEvent( + const newEventOfOverlappingPeriod = new EffectEvent( event.startTime, oldLastEventEndTime - event.startTime, - lastEvent.buffDefinition, + lastEvent.effectDefinition, lastEvent.maxStacks, newStacksOfOverlappingPeriod ); super.addEvent(newEventOfOverlappingPeriod); - const newEvent = new BuffEvent( + const newEvent = new EffectEvent( newEventOfOverlappingPeriod.endTime, event.endTime - newEventOfOverlappingPeriod.endTime, - event.buffDefinition, + event.effectDefinition, event.maxStacks, event.stacks ); diff --git a/src/models/v4/timeline/timeline.ts b/src/models/v4/timeline/timeline.ts index 9ba53414..368359fc 100644 --- a/src/models/v4/timeline/timeline.ts +++ b/src/models/v4/timeline/timeline.ts @@ -25,9 +25,7 @@ export class Timeline { /** Returns events that have any sort of overlap with the period of start time to end time */ public getEventsOverlapping(startTime: number, endTime: number): TEvent[] { return this._events.filter( - (event) => - (event.endTime > startTime && event.endTime <= endTime) || - (event.startTime >= startTime && event.startTime < endTime) + (event) => event.startTime < endTime && event.endTime >= startTime ); } } diff --git a/src/models/weapon-definition.ts b/src/models/weapon-definition.ts index f010eb0e..f3331265 100644 --- a/src/models/weapon-definition.ts +++ b/src/models/weapon-definition.ts @@ -10,6 +10,8 @@ import type { SkillAttackDefinition, } from './v4/attack-definition'; import type { AttackBuffDefinition } from './v4/buffs/attack-buff-definition'; +import type { DamageBuffDefinition } from './v4/buffs/damage-buff-definition'; +import type { EffectDefinition } from './v4/effect-definition'; import type { WeaponAttackPercentBuffDefinition, WeaponCritRateBuffDefinition, @@ -35,10 +37,13 @@ export interface WeaponDefinition { skills: SkillAttackDefinition[]; discharge: DischargeAttackDefinition; + effects: EffectDefinition[]; + commonAttackBuffs: CommonWeaponAttackBuffId[]; commonDamageBuffs: CommonWeaponDamageBuffId[]; attackBuffs: AttackBuffDefinition[]; + damageBuffs: DamageBuffDefinition[]; } export function getWeaponDefinition(id: WeaponName): WeaponDefinition { From 8a01c43a359891f69a2a165fcbbf61a380419014 Mon Sep 17 00:00:00 2001 From: apache1123 Date: Thu, 21 Mar 2024 18:07:08 +1300 Subject: [PATCH 3/4] Enforce atk & effect cooldowns --- src/constants/simulacrum-traits.ts | 2 -- src/models/v4/combat-simulator.ts | 5 +++-- .../__tests__/effect-timeline.test.ts | 21 +++++++++++++++++++ src/models/v4/timeline/attack-event.ts | 4 ++++ src/models/v4/timeline/effect-event.ts | 8 +++++++ src/models/v4/timeline/effect-timeline.ts | 5 +++++ 6 files changed, 41 insertions(+), 4 deletions(-) diff --git a/src/constants/simulacrum-traits.ts b/src/constants/simulacrum-traits.ts index fe9dccfa..a77f882f 100644 --- a/src/constants/simulacrum-traits.ts +++ b/src/constants/simulacrum-traits.ts @@ -1026,7 +1026,6 @@ export const simulacrumTraits: Data = { duration: { value: 8000, }, - // TODO: implement cooldown logic on buffs cooldown: 16000, }, { @@ -1044,7 +1043,6 @@ export const simulacrumTraits: Data = { duration: { value: 8000, }, - // TODO: implement cooldown logic on buffs cooldown: 16000, }, ], diff --git a/src/models/v4/combat-simulator.ts b/src/models/v4/combat-simulator.ts index 9f5b8061..597d7fa2 100644 --- a/src/models/v4/combat-simulator.ts +++ b/src/models/v4/combat-simulator.ts @@ -90,7 +90,9 @@ export class CombatSimulator { nextEarliestAttackStartTime ) ?? []; return attackEventsToCheck.every( - (x) => x.attackDefinition.id !== attack.attackDefinition.id + (x) => + x.attackDefinition.id !== attack.attackDefinition.id || + x.cooldownEndsAt <= nextEarliestAttackStartTime ); }); } @@ -354,7 +356,6 @@ export class CombatSimulator { effectDefinition: EffectDefinition, attackEvent: AttackEvent ): boolean { - // TODO: cooldown const { triggeredBy } = effectDefinition; const { diff --git a/src/models/v4/timeline/__tests__/effect-timeline.test.ts b/src/models/v4/timeline/__tests__/effect-timeline.test.ts index a967913b..fa7c58cd 100644 --- a/src/models/v4/timeline/__tests__/effect-timeline.test.ts +++ b/src/models/v4/timeline/__tests__/effect-timeline.test.ts @@ -89,4 +89,25 @@ describe('Effect timeline', () => { expect(sut.events[1].startTime).toBe(10); expect(sut.events[1].endTime).toBe(20); }); + + it('does not add new event if the last event is still on cooldown', () => { + const sut = new EffectTimeline(); + const eventWithCooldown = new EffectEvent(0, 10, mockEffectDefinition); + eventWithCooldown.cooldown = 5; + sut.addEvent(eventWithCooldown); + + expect(sut.events.length).toBe(1); + expect(sut.lastEvent?.startTime).toBe(0); + expect(sut.lastEvent?.endTime).toBe(10); + + sut.addEvent(new EffectEvent(3, 10, mockEffectDefinition)); + expect(sut.events.length).toBe(1); + expect(sut.lastEvent?.startTime).toBe(0); + expect(sut.lastEvent?.endTime).toBe(10); + + sut.addEvent(new EffectEvent(7, 10, mockEffectDefinition)); + expect(sut.events.length).toBe(1); + expect(sut.lastEvent?.startTime).toBe(0); + expect(sut.lastEvent?.endTime).toBe(17); + }); }); diff --git a/src/models/v4/timeline/attack-event.ts b/src/models/v4/timeline/attack-event.ts index 9c929519..65b446fb 100644 --- a/src/models/v4/timeline/attack-event.ts +++ b/src/models/v4/timeline/attack-event.ts @@ -22,4 +22,8 @@ export class AttackEvent extends TimelineEvent implements Attack { public get displayName() { return this.attackDefinition.displayName; } + + public get cooldownEndsAt() { + return this.startTime + this.cooldown; + } } diff --git a/src/models/v4/timeline/effect-event.ts b/src/models/v4/timeline/effect-event.ts index b2a40f49..338f61b0 100644 --- a/src/models/v4/timeline/effect-event.ts +++ b/src/models/v4/timeline/effect-event.ts @@ -2,6 +2,8 @@ import type { EffectDefinition } from '../effect-definition'; import { TimelineEvent } from './timeline-event'; export class EffectEvent extends TimelineEvent { + public cooldown: number; + public constructor( public startTime: number, public duration: number, @@ -10,9 +12,15 @@ export class EffectEvent extends TimelineEvent { public stacks: number = 1 ) { super(startTime, duration); + + this.cooldown = effectDefinition.cooldown; } public get displayName(): string { return this.effectDefinition.displayName; } + + public get cooldownEndsAt() { + return this.startTime + this.cooldown; + } } diff --git a/src/models/v4/timeline/effect-timeline.ts b/src/models/v4/timeline/effect-timeline.ts index 3cc945ee..b3b49780 100644 --- a/src/models/v4/timeline/effect-timeline.ts +++ b/src/models/v4/timeline/effect-timeline.ts @@ -9,6 +9,11 @@ export class EffectTimeline extends Timeline { public addEvent(event: EffectEvent) { const { lastEvent } = this; + // Last event still on cooldown + if (lastEvent && lastEvent.cooldownEndsAt > event.startTime) { + return; + } + // Event does not overlap with an existing one whatsoever, add new event as usual if (!lastEvent || event.startTime > lastEvent.endTime) { super.addEvent(event); From 77b5bf2af1d0a94d69d91018640ce8e871aac838 Mon Sep 17 00:00:00 2001 From: apache1123 Date: Sun, 24 Mar 2024 00:12:00 +1300 Subject: [PATCH 4/4] Refactor combat simulator effect timelines --- src/constants/common-weapon-attack-buffs.ts | 2 +- .../CombatSimulatorTimeline.tsx | 107 ++--- .../combat-simulator.test.ts.snap | 12 + .../v4/__tests__/combat-simulator.test.ts | 275 ++++++------- src/models/v4/attacks/attack-command.ts | 3 + .../v4/{ => attacks}/attack-definition.ts | 4 +- src/models/v4/{ => attacks}/attack.ts | 4 +- src/models/v4/buffs/attack-buff-definition.ts | 2 +- .../common-weapon-attack-buff-definition.ts | 6 + src/models/v4/buffs/damage-buff-definition.ts | 2 +- .../v4/buffs/miscellaneous-buff-definition.ts | 2 +- src/models/v4/combat-simulator.ts | 376 +----------------- .../common-weapon-attack-buff-definition.ts | 6 - .../v4/{ => effects}/effect-definition.ts | 6 +- src/models/v4/effects/effect-evaluator.ts | 163 ++++++++ src/models/v4/effects/effect-group.ts | 101 +++++ src/models/v4/effects/effect-pool.ts | 121 ++++++ .../__tests__/attack-timeline.test.ts | 19 +- .../__tests__/charge-timeline.test.ts | 13 +- .../__tests__/effect-timeline.test.ts | 20 +- .../__tests__/timeline.test.ts | 2 +- .../{timeline => timelines}/attack-event.ts | 16 +- .../attack-timeline.ts | 0 .../{timeline => timelines}/charge-event.ts | 0 .../charge-timeline.ts | 0 .../{timeline => timelines}/effect-event.ts | 2 +- .../effect-timeline.ts | 0 .../{timeline => timelines}/timeline-event.ts | 2 - .../v4/{timeline => timelines}/timeline.ts | 16 + src/models/weapon-definition.ts | 4 +- 30 files changed, 631 insertions(+), 655 deletions(-) create mode 100644 src/models/v4/attacks/attack-command.ts rename src/models/v4/{ => attacks}/attack-definition.ts (88%) rename src/models/v4/{ => attacks}/attack.ts (76%) create mode 100644 src/models/v4/buffs/common-weapon-attack-buff-definition.ts delete mode 100644 src/models/v4/common-weapon-attack-buff-definition.ts rename src/models/v4/{ => effects}/effect-definition.ts (91%) create mode 100644 src/models/v4/effects/effect-evaluator.ts create mode 100644 src/models/v4/effects/effect-group.ts create mode 100644 src/models/v4/effects/effect-pool.ts rename src/models/v4/{timeline => timelines}/__tests__/attack-timeline.test.ts (56%) rename src/models/v4/{timeline => timelines}/__tests__/charge-timeline.test.ts (76%) rename src/models/v4/{timeline => timelines}/__tests__/effect-timeline.test.ts (86%) rename src/models/v4/{timeline => timelines}/__tests__/timeline.test.ts (97%) rename src/models/v4/{timeline => timelines}/attack-event.ts (56%) rename src/models/v4/{timeline => timelines}/attack-timeline.ts (100%) rename src/models/v4/{timeline => timelines}/charge-event.ts (100%) rename src/models/v4/{timeline => timelines}/charge-timeline.ts (100%) rename src/models/v4/{timeline => timelines}/effect-event.ts (89%) rename src/models/v4/{timeline => timelines}/effect-timeline.ts (100%) rename src/models/v4/{timeline => timelines}/timeline-event.ts (86%) rename src/models/v4/{timeline => timelines}/timeline.ts (69%) diff --git a/src/constants/common-weapon-attack-buffs.ts b/src/constants/common-weapon-attack-buffs.ts index 8bc3762c..ec0edb65 100644 --- a/src/constants/common-weapon-attack-buffs.ts +++ b/src/constants/common-weapon-attack-buffs.ts @@ -1,4 +1,4 @@ -import type { CommonWeaponAttackBuffDefinition } from '../models/v4/common-weapon-attack-buff-definition'; +import type { CommonWeaponAttackBuffDefinition } from '../models/v4/buffs/common-weapon-attack-buff-definition'; export type CommonWeaponAttackBuffId = 'volt-resonance' | 'frost-resonance'; diff --git a/src/features/combat-simulator/CombatSimulatorTimeline.tsx b/src/features/combat-simulator/CombatSimulatorTimeline.tsx index cc0f5a61..67eadfc1 100644 --- a/src/features/combat-simulator/CombatSimulatorTimeline.tsx +++ b/src/features/combat-simulator/CombatSimulatorTimeline.tsx @@ -13,12 +13,11 @@ import { Loadout } from '../../models/loadout'; import { Team } from '../../models/team'; import { CombatSimulator } from '../../models/v4/combat-simulator'; import { Relics } from '../../models/v4/relics'; -import type { TimelineEvent } from '../../models/v4/timeline/timeline-event'; +import type { TimelineEvent } from '../../models/v4/timelines/timeline-event'; import { Weapon } from '../../models/weapon'; import { AttackBuffEventRenderer } from './AttackBuffEventRenderer'; import { AttackEventRenderer } from './AttackEventRenderer'; import { CombatSimulatorTimelineScaleRenderer } from './CombatSimulatorTimelineScaleRenderer'; -import { DamageBuffEventRenderer } from './DamageBuffEventRenderer'; import styles from './styles.module.css'; const weapon1 = new Weapon(weaponDefinitions.byId['Brevey']); @@ -70,6 +69,8 @@ export interface CombatSimulatorTimelineAction extends TimelineAction { export type CombatSimulatorTimelineEffectId = | 'attack-event' + | 'effect-event' + // TODO: unused below | 'attack-buff-event' | 'damage-buff-event'; export interface CombatSimulatorTimelineEffect extends TimelineEffect { @@ -83,6 +84,10 @@ const effects: Record< 'attack-event': { id: 'attack-event', }, + 'effect-event': { + id: 'effect-event', + }, + // TODO: unused below 'attack-buff-event': { id: 'attack-buff-event', }, @@ -113,84 +118,22 @@ export function CombatSimulatorTimeline() { }); } - for (const [ - buffId, - buffTimeline, - ] of combatSimulatorSnap.weaponDamageBuffTimelines) { - editorData.push({ - id: buffId, - displayName: buffTimeline.events[0].displayName, - actions: buffTimeline.events.map( - (damageBuffEvent, index) => ({ - id: `${buffId}-weapon-damage-buff-${index}`, - start: damageBuffEvent.startTime, - end: damageBuffEvent.endTime, - effectId: 'damage-buff-event', - event: damageBuffEvent, - }) - ), - classNames: [styles.timelineRow], - }); - } - - for (const [ - buffId, - buffTimeline, - ] of combatSimulatorSnap.weaponAttackBuffTimelines) { - editorData.push({ - id: buffId, - displayName: buffTimeline.events[0].displayName, - actions: buffTimeline.events.map( - (attackBuffEvent, index) => ({ - id: `${buffId}-weapon-passive-attack-buff-${index}`, - start: attackBuffEvent.startTime, - end: attackBuffEvent.endTime, - effectId: 'attack-buff-event', - event: attackBuffEvent, - }) - ), - classNames: [styles.timelineRow], - }); - } - - for (const [ - buffId, - buffTimeline, - ] of combatSimulatorSnap.traitDamageBuffTimelines) { - editorData.push({ - id: buffId, - displayName: buffTimeline.events[0].displayName, - actions: buffTimeline.events.map( - (damageBuffEvent, index) => ({ - id: `${buffId}-simulacrum-trait-damage-buff-${index}`, - start: damageBuffEvent.startTime, - end: damageBuffEvent.endTime, - effectId: 'damage-buff-event', - event: damageBuffEvent, - }) - ), - classNames: [styles.timelineRow], - }); - } - - for (const [ - buffId, - buffTimeline, - ] of combatSimulatorSnap.relicDamageBuffTimelines) { - editorData.push({ - id: buffId, - displayName: buffTimeline.events[0].displayName, - actions: buffTimeline.events.map( - (damageBuffEvent, index) => ({ - id: `${buffId}-relic-passive-damage-buff-${index}`, - start: damageBuffEvent.startTime, - end: damageBuffEvent.endTime, - effectId: 'damage-buff-event', - event: damageBuffEvent, - }) - ), - classNames: [styles.timelineRow], - }); + for (const effectGroup of combatSimulatorSnap.effectPool.effectGroups) { + for (const [effectId, effectTimeline] of effectGroup.effectTimelines) { + editorData.push({ + id: effectId, + displayName: effectTimeline.displayName, + actions: effectTimeline.events.map( + (effectEvent, index) => ({ + id: `${effectId}-${index}`, + start: effectEvent.startTime, + end: effectEvent.endTime, + effectId: 'effect-event', + event: effectEvent, + }) + ), + }); + } } return ( @@ -203,10 +146,8 @@ export function CombatSimulatorTimeline() { const typedAction = action as CombatSimulatorTimelineAction; if (typedAction.effectId === 'attack-event') { return ; - } else if (typedAction.effectId === 'attack-buff-event') { + } else { return ; - } else if (typedAction.effectId === 'damage-buff-event') { - return ; } }} scale={10000} // 10s diff --git a/src/models/v4/__tests__/__snapshots__/combat-simulator.test.ts.snap b/src/models/v4/__tests__/__snapshots__/combat-simulator.test.ts.snap index 38310760..8f6cfddd 100644 --- a/src/models/v4/__tests__/__snapshots__/combat-simulator.test.ts.snap +++ b/src/models/v4/__tests__/__snapshots__/combat-simulator.test.ts.snap @@ -883,6 +883,7 @@ Map { "Alternative Destiny passive damage buff" => EffectTimeline { "_events": [ EffectEvent { + "cooldown": 0, "duration": 150000, "effectDefinition": { "cooldown": 0, @@ -909,10 +910,13 @@ Map { "startTime": 0, }, ], + "displayName": "Alternative Destiny passive damage buff", + "totalDuration": 150000, }, "Cybernetic arm passive damage buff" => EffectTimeline { "_events": [ EffectEvent { + "cooldown": 0, "duration": 150000, "effectDefinition": { "cooldown": 0, @@ -939,10 +943,13 @@ Map { "startTime": 0, }, ], + "displayName": "Cybernetic arm passive damage buff", + "totalDuration": 150000, }, "Thalassic Heart passive damage buff" => EffectTimeline { "_events": [ EffectEvent { + "cooldown": 0, "duration": 150000, "effectDefinition": { "cooldown": 0, @@ -969,10 +976,13 @@ Map { "startTime": 0, }, ], + "displayName": "Thalassic Heart passive damage buff", + "totalDuration": 150000, }, "Triple Mask damage buff" => EffectTimeline { "_events": [ EffectEvent { + "cooldown": 0, "duration": 150000, "effectDefinition": { "cooldown": 0, @@ -1003,6 +1013,8 @@ Map { "startTime": 0, }, ], + "displayName": "Triple Mask damage buff", + "totalDuration": 150000, }, } `; diff --git a/src/models/v4/__tests__/combat-simulator.test.ts b/src/models/v4/__tests__/combat-simulator.test.ts index 384ec79a..868237e5 100644 --- a/src/models/v4/__tests__/combat-simulator.test.ts +++ b/src/models/v4/__tests__/combat-simulator.test.ts @@ -7,7 +7,7 @@ import { GearSet } from '../../gear-set'; import { Loadout } from '../../loadout'; import { Team } from '../../team'; import { Weapon } from '../../weapon'; -import type { AttackDefinition } from '../attack-definition'; +import type { AttackDefinition } from '../attacks/attack-definition'; import { CombatSimulator } from '../combat-simulator'; import { Relics } from '../relics'; @@ -67,19 +67,6 @@ describe('CombatSimulator', () => { it('does not include attacks if they are on cooldown', () => { const sut = new CombatSimulator(combatDuration, loadout, relics); - // sut.performAttack({ - // weapon: weapon1, - // attackDefinition: weapon1.definition.skills[0], - // }); - - // expect( - // sut.availableAttacks - // .get(weapon1) - // ?.some( - // (attackDefinition) => - // attackDefinition.id === weapon1.definition.skills[0].id - // ) - // ).toBe(false); const attack = { weapon: weapon1, @@ -93,9 +80,6 @@ describe('CombatSimulator', () => { it('does not include discharges if there is no full charge available', () => { const sut = new CombatSimulator(combatDuration, loadout, relics); expect( - // Array.from(sut.availableAttacks.values()) - // .flat() - // .some((attackDefinition) => attackDefinition.type === 'discharge') sut.availableAttacks.some( (attack) => attack.attackDefinition.type === 'discharge' ) @@ -106,9 +90,6 @@ describe('CombatSimulator', () => { attackDefinition: weapon1.definition.normalAttacks[0], }); expect( - // Array.from(sut.availableAttacks.values()) - // .flat() - // .some((attackDefinition) => attackDefinition.type === 'discharge') sut.availableAttacks.some( (attack) => attack.attackDefinition.type === 'discharge' ) @@ -125,9 +106,6 @@ describe('CombatSimulator', () => { }); }, 20); - // const dischargeAttacks = Array.from(sut.availableAttacks.values()) - // .flat() - // .filter((attackDefinition) => attackDefinition.type === 'discharge'); const dischargeAttacks = sut.availableAttacks.filter( (attack) => attack.attackDefinition.type === 'discharge' ); @@ -185,7 +163,7 @@ describe('CombatSimulator', () => { }); expect( - sut.weaponDamageBuffTimelines.get('force-impact')?.events[0] + sut.effectPool.getEffectTimeline('force-impact')?.events[0] ).toBeDefined(); }); }); @@ -199,11 +177,11 @@ describe('CombatSimulator', () => { }); const voltResonanceBuffEvent = - sut.weaponAttackBuffTimelines.get('volt-resonance')?.events[0]; + sut.effectPool.getEffectTimeline('volt-resonance')?.events[0]; expect(voltResonanceBuffEvent).toBeDefined(); const frostResonanceBuffEvent = - sut.weaponAttackBuffTimelines.get('frost-resonance')?.events[0]; + sut.effectPool.getEffectTimeline('frost-resonance')?.events[0]; expect(frostResonanceBuffEvent).toBeDefined(); if (frostResonanceBuffEvent) { expect(frostResonanceBuffEvent.startTime).toBe(0); @@ -222,14 +200,14 @@ describe('CombatSimulator', () => { }); const voltResonanceBuffEvent = - sut.weaponAttackBuffTimelines.get('volt-resonance')?.events[0]; + sut.effectPool.getEffectTimeline('volt-resonance')?.events[0]; expect(voltResonanceBuffEvent).toBeDefined(); if (voltResonanceBuffEvent) { expect(voltResonanceBuffEvent.stacks).toBe(1); } const frostResonanceBuffEvent = - sut.weaponAttackBuffTimelines.get('frost-resonance')?.events[0]; + sut.effectPool.getEffectTimeline('frost-resonance')?.events[0]; expect(frostResonanceBuffEvent).toBeUndefined(); }); }); @@ -245,7 +223,7 @@ describe('CombatSimulator', () => { attackDefinition: weapon3.definition.normalAttacks[0], }); - const timeline = sut.weaponAttackBuffTimelines.get( + const timeline = sut.effectPool.getEffectTimeline( weaponDefinitions.byId['Nan Yin'].attackBuffs[0].id ); expect(timeline).toBeDefined(); @@ -265,7 +243,7 @@ describe('CombatSimulator', () => { attackDefinition: weapon3.definition.normalAttacks[0], }); expect( - sut.weaponAttackBuffTimelines.has( + sut.effectPool.hasEffect( weaponDefinitions.byId['Nan Yin'].attackBuffs[0].id ) ).toBe(false); @@ -279,7 +257,7 @@ describe('CombatSimulator', () => { attackDefinition: weapon3.definition.normalAttacks[0], }); expect( - sut2.weaponAttackBuffTimelines.has( + sut2.effectPool.hasEffect( weaponDefinitions.byId['Nan Yin'].attackBuffs[0].id ) ).toBe(true); @@ -307,7 +285,7 @@ describe('CombatSimulator', () => { attackDefinition: weapon2.definition.normalAttacks[0], }); - const voltBuffEvent = sut.weaponDamageBuffTimelines.get( + const voltBuffEvent = sut.effectPool.getEffectTimeline( 'brevey-damage-buff-pact-amplification-volt' )?.lastEvent; expect(voltBuffEvent).toBeDefined(); @@ -320,7 +298,7 @@ describe('CombatSimulator', () => { ); } - const frostBuffEvent = sut.weaponDamageBuffTimelines.get( + const frostBuffEvent = sut.effectPool.getEffectTimeline( 'brevey-damage-buff-pact-amplification-frost' )?.lastEvent; expect(frostBuffEvent).toBeDefined(); @@ -349,7 +327,9 @@ describe('CombatSimulator', () => { attackDefinition: weapon1.definition.normalAttacks[0], }); - expect(sut.relicDamageBuffTimelines).toMatchSnapshot(); + expect( + sut.effectPool.getEffectGroup('Relic passive buffs')?.effectTimelines + ).toMatchSnapshot(); }); }); @@ -366,7 +346,7 @@ describe('CombatSimulator', () => { }); const damageBuffEvent = - sut.traitDamageBuffTimelines.get('brevey-trait')?.events[0]; + sut.effectPool.getEffectTimeline('brevey-trait')?.events[0]; if (damageBuffEvent) { expect(damageBuffEvent.startTime).toBe(0); expect(damageBuffEvent.duration).toBe(combatDuration); @@ -384,7 +364,7 @@ describe('CombatSimulator', () => { attackDefinition: weapon1.definition.normalAttacks[0], }); - const damageBuffEvent = sut.traitDamageBuffTimelines.get( + const damageBuffEvent = sut.effectPool.getEffectTimeline( 'brevey-trait-additional' )?.events[0]; expect(damageBuffEvent).toBeDefined(); @@ -403,9 +383,9 @@ describe('CombatSimulator', () => { attackDefinition: weapon2.definition.normalAttacks[0], }); - expect( - sut2.traitDamageBuffTimelines.has('brevey-trait-additional') - ).toBe(false); + expect(sut2.effectPool.hasEffect('brevey-trait-additional')).toBe( + false + ); }); it('is added, based on the number of weapons of different elements', () => { @@ -430,12 +410,12 @@ describe('CombatSimulator', () => { attackDefinition: weapon1.definition.normalAttacks[0], }); - expect( - sut.traitDamageBuffTimelines.has(fenrir2ElementalTypesBuff.id) - ).toBe(false); - expect( - sut.traitDamageBuffTimelines.has(fenrir3ElementalTypesBuff.id) - ).toBe(true); + expect(sut.effectPool.hasEffect(fenrir2ElementalTypesBuff.id)).toBe( + false + ); + expect(sut.effectPool.hasEffect(fenrir3ElementalTypesBuff.id)).toBe( + true + ); // 2 elemental types team.weapon3 = new Weapon(weaponDefinitions.byId['Huang (Mimi)']); @@ -445,12 +425,12 @@ describe('CombatSimulator', () => { attackDefinition: weapon1.definition.normalAttacks[0], }); - expect( - sut2.traitDamageBuffTimelines.has(fenrir2ElementalTypesBuff.id) - ).toBe(true); - expect( - sut2.traitDamageBuffTimelines.has(fenrir3ElementalTypesBuff.id) - ).toBe(false); + expect(sut2.effectPool.hasEffect(fenrir2ElementalTypesBuff.id)).toBe( + true + ); + expect(sut2.effectPool.hasEffect(fenrir3ElementalTypesBuff.id)).toBe( + false + ); }); it('is added, based on the number of weapons of an element', () => { @@ -470,9 +450,7 @@ describe('CombatSimulator', () => { weapon: weapon1, attackDefinition: weapon1.definition.normalAttacks[0], }); - expect(sut.traitDamageBuffTimelines.has(mimiTripleVoltBuff.id)).toBe( - false - ); + expect(sut.effectPool.hasEffect(mimiTripleVoltBuff.id)).toBe(false); // Positive case team.weapon3 = new Weapon(weaponDefinitions.byId['Huang (Mimi)']); @@ -481,9 +459,7 @@ describe('CombatSimulator', () => { weapon: weapon1, attackDefinition: weapon1.definition.normalAttacks[0], }); - expect(sut2.traitDamageBuffTimelines.has(mimiTripleVoltBuff.id)).toBe( - true - ); + expect(sut2.effectPool.hasEffect(mimiTripleVoltBuff.id)).toBe(true); }); it('is added, based on the team weapon resonance', () => { @@ -496,7 +472,7 @@ describe('CombatSimulator', () => { weapon: weapon1, attackDefinition: weapon1.definition.normalAttacks[0], }); - expect(sut.traitDamageBuffTimelines.has('lan-trait')).toBe(false); + expect(sut.effectPool.hasEffect('lan-trait')).toBe(false); // Positive case team.weapon2 = new Weapon(weaponDefinitions.byId['Huang (Mimi)']); @@ -506,7 +482,7 @@ describe('CombatSimulator', () => { weapon: weapon1, attackDefinition: weapon1.definition.normalAttacks[0], }); - expect(sut2.traitDamageBuffTimelines.has('lan-trait')).toBe(true); + expect(sut2.effectPool.hasEffect('lan-trait')).toBe(true); }); it('is added only for a later segment of the combat duration', () => { @@ -520,7 +496,7 @@ describe('CombatSimulator', () => { attackDefinition: weapon1.definition.normalAttacks[0], }); - const buffEvent = sut.traitDamageBuffTimelines.get( + const buffEvent = sut.effectPool.getEffectTimeline( 'yanmiao-trait-weapon-buff' )?.events[0]; expect(buffEvent).toBeDefined(); @@ -544,7 +520,7 @@ describe('CombatSimulator', () => { attackDefinition: weapon1.definition.skills[0], }); - const damageBuffEvent = sut.traitDamageBuffTimelines.get( + const damageBuffEvent = sut.effectPool.getEffectTimeline( alyssTrait.damageBuffs[0].id )?.events[0]; if (damageBuffEvent) { @@ -567,7 +543,7 @@ describe('CombatSimulator', () => { attackDefinition: weapon1.definition.normalAttacks[0], }); - const damageBuffEvent = sut.traitDamageBuffTimelines.get( + const damageBuffEvent = sut.effectPool.getEffectTimeline( crowTrait.damageBuffs[1].id )?.events[0]; expect(damageBuffEvent).toBeDefined(); @@ -591,7 +567,7 @@ describe('CombatSimulator', () => { }); expect( feiseTrait.damageBuffs.some((buff) => - sut.traitDamageBuffTimelines.has(buff.id) + sut.effectPool.hasEffect(buff.id) ) ).toBe(false); @@ -605,7 +581,7 @@ describe('CombatSimulator', () => { }); expect( feiseTrait.damageBuffs.some((buff) => - sut2.traitDamageBuffTimelines.has(buff.id) + sut2.effectPool.hasEffect(buff.id) ) ).toBe(true); }); @@ -643,15 +619,9 @@ describe('CombatSimulator', () => { attackDefinition: feiseWeapon.definition.skills[0], }); - expect(sut1.traitDamageBuffTimelines.has(feiseTrait1FlameBuff.id)).toBe( - true - ); - expect(sut1.traitDamageBuffTimelines.has(feiseTrait2FlameBuff.id)).toBe( - false - ); - expect(sut1.traitDamageBuffTimelines.has(feiseTrait3FlameBuff.id)).toBe( - false - ); + expect(sut1.effectPool.hasEffect(feiseTrait1FlameBuff.id)).toBe(true); + expect(sut1.effectPool.hasEffect(feiseTrait2FlameBuff.id)).toBe(false); + expect(sut1.effectPool.hasEffect(feiseTrait3FlameBuff.id)).toBe(false); // 2 flame weapons team.weapon1 = feiseWeapon; @@ -662,15 +632,9 @@ describe('CombatSimulator', () => { attackDefinition: feiseWeapon.definition.skills[0], }); - expect(sut2.traitDamageBuffTimelines.has(feiseTrait1FlameBuff.id)).toBe( - false - ); - expect(sut2.traitDamageBuffTimelines.has(feiseTrait2FlameBuff.id)).toBe( - true - ); - expect(sut2.traitDamageBuffTimelines.has(feiseTrait3FlameBuff.id)).toBe( - false - ); + expect(sut2.effectPool.hasEffect(feiseTrait1FlameBuff.id)).toBe(false); + expect(sut2.effectPool.hasEffect(feiseTrait2FlameBuff.id)).toBe(true); + expect(sut2.effectPool.hasEffect(feiseTrait3FlameBuff.id)).toBe(false); // 3 flame weapons team.weapon1 = feiseWeapon; @@ -682,15 +646,9 @@ describe('CombatSimulator', () => { attackDefinition: feiseWeapon.definition.skills[0], }); - expect(sut3.traitDamageBuffTimelines.has(feiseTrait1FlameBuff.id)).toBe( - false - ); - expect(sut3.traitDamageBuffTimelines.has(feiseTrait2FlameBuff.id)).toBe( - false - ); - expect(sut3.traitDamageBuffTimelines.has(feiseTrait3FlameBuff.id)).toBe( - true - ); + expect(sut3.effectPool.hasEffect(feiseTrait1FlameBuff.id)).toBe(false); + expect(sut3.effectPool.hasEffect(feiseTrait2FlameBuff.id)).toBe(false); + expect(sut3.effectPool.hasEffect(feiseTrait3FlameBuff.id)).toBe(true); }); it('is added, triggered by active weapon', () => { @@ -713,20 +671,24 @@ describe('CombatSimulator', () => { 'nanyin-trait-active-weapon-2-non-altered', 'nanyin-trait-active-weapon-3-non-altered', ]; - expect( - Array.from(sut.traitDamageBuffTimelines.keys()).some((buffId) => - buffIdsToCheck.includes(buffId) - ) - ).toBe(true); - - for (const [buffId, timeline] of sut.traitDamageBuffTimelines) { - if (!buffIdsToCheck.includes(buffId)) continue; - - expect(timeline.events.length).toBe(1); - expect(timeline.lastEvent?.startTime).toBe(attackStartTime); - expect(timeline.lastEvent?.duration).toBe( - weapon3.definition.normalAttacks[0].duration - ); + const effectGroup = sut.effectPool.getEffectGroup('Trait damage buffs'); + expect(effectGroup).toBeDefined(); + if (effectGroup) { + expect( + Array.from(effectGroup.effectTimelines.keys()).some((buffId) => + buffIdsToCheck.includes(buffId) + ) + ).toBe(true); + + for (const [buffId, timeline] of effectGroup.effectTimelines) { + if (!buffIdsToCheck.includes(buffId)) continue; + + expect(timeline.events.length).toBe(1); + expect(timeline.lastEvent?.startTime).toBe(attackStartTime); + expect(timeline.lastEvent?.duration).toBe( + weapon3.definition.normalAttacks[0].duration + ); + } } }); @@ -746,12 +708,8 @@ describe('CombatSimulator', () => { attackDefinition: weapon3.definition.normalAttacks[0], }); - expect(sut.traitDamageBuffTimelines.has(nanyin1NonAlteredBuffId)).toBe( - false - ); - expect(sut.traitDamageBuffTimelines.has(nanyin2NanAlteredBuffId)).toBe( - true - ); + expect(sut.effectPool.hasEffect(nanyin1NonAlteredBuffId)).toBe(false); + expect(sut.effectPool.hasEffect(nanyin2NanAlteredBuffId)).toBe(true); // 1 non-altered weapon team.weapon1 = new Weapon(weaponDefinitions.byId['Fiona']); @@ -761,12 +719,8 @@ describe('CombatSimulator', () => { attackDefinition: weapon3.definition.normalAttacks[0], }); - expect(sut2.traitDamageBuffTimelines.has(nanyin1NonAlteredBuffId)).toBe( - true - ); - expect(sut2.traitDamageBuffTimelines.has(nanyin2NanAlteredBuffId)).toBe( - false - ); + expect(sut2.effectPool.hasEffect(nanyin1NonAlteredBuffId)).toBe(true); + expect(sut2.effectPool.hasEffect(nanyin2NanAlteredBuffId)).toBe(false); }); it('is added, triggered by weapon skill of a specific element weapon', () => { @@ -779,7 +733,7 @@ describe('CombatSimulator', () => { attackDefinition: weapon1.definition.skills[0], }); - expect(sut.traitDamageBuffTimelines.has('tianlang-trait')).toBe(true); + expect(sut.effectPool.hasEffect('tianlang-trait')).toBe(true); }); it('is added, triggered by weapon discharge of a specific element weapon', () => { @@ -799,7 +753,7 @@ describe('CombatSimulator', () => { attackDefinition: weapon1.definition.discharge, }); - expect(sut.traitDamageBuffTimelines.has('tianlang-trait')).toBe(true); + expect(sut.effectPool.hasEffect('tianlang-trait')).toBe(true); }); }); @@ -814,7 +768,7 @@ describe('CombatSimulator', () => { attackDefinition: weapon1.definition.normalAttacks[0], }); - expect(sut.traitAttackBuffTimelines.has('frigg-trait')).toBe(true); + expect(sut.effectPool.hasEffect('frigg-trait')).toBe(true); }); it('is added, triggered by weapon skill with a weapon type requirement', () => { @@ -828,7 +782,7 @@ describe('CombatSimulator', () => { attackDefinition: weapon1.definition.skills[0], }); - const attackBuffEvent = sut.traitAttackBuffTimelines.get( + const attackBuffEvent = sut.effectPool.getEffectTimeline( cocoTrait.attackBuffs[0].id )?.events[0]; expect(attackBuffEvent).toBeDefined(); @@ -846,9 +800,9 @@ describe('CombatSimulator', () => { attackDefinition: weapon2.definition.skills[0], }); - expect( - sut2.traitAttackBuffTimelines.has(cocoTrait.attackBuffs[0].id) - ).toBe(false); + expect(sut2.effectPool.hasEffect(cocoTrait.attackBuffs[0].id)).toBe( + false + ); }); it('is added, triggered by weapon discharge with a weapon type requirement', () => { @@ -869,7 +823,7 @@ describe('CombatSimulator', () => { attackDefinition: weapon1.definition.discharge, }); - const attackBuffEvent = sut.traitAttackBuffTimelines.get( + const attackBuffEvent = sut.effectPool.getEffectTimeline( cocoTrait.attackBuffs[0].id )?.events[0]; expect(attackBuffEvent).toBeDefined(); @@ -896,9 +850,9 @@ describe('CombatSimulator', () => { attackDefinition: weapon2.definition.discharge, }); - expect( - sut2.traitAttackBuffTimelines.has(cocoTrait.attackBuffs[0].id) - ).toBe(false); + expect(sut2.effectPool.hasEffect(cocoTrait.attackBuffs[0].id)).toBe( + false + ); }); it('is added, triggered by a specific weapon attack', () => { @@ -914,9 +868,7 @@ describe('CombatSimulator', () => { attackDefinition: rubyWeapon.definition.dodgeAttacks[0], }); - expect(sut.traitAttackBuffTimelines.has('ruby-trait-dolly-atk')).toBe( - true - ); + expect(sut.effectPool.hasEffect('ruby-trait-dolly-atk')).toBe(true); }); it('is added, triggered by any weapon skill', () => { @@ -930,11 +882,15 @@ describe('CombatSimulator', () => { }); const buffIdsToCheck = shiroTrait.attackBuffs.map((buff) => buff.id); - const addedBuffIds = Array.from(sut.traitAttackBuffTimelines.keys()); - expect(addedBuffIds.length).not.toBe(0); - expect( - addedBuffIds.every((buffId) => buffIdsToCheck.includes(buffId)) - ).toBe(true); + const effectGroup = sut.effectPool.getEffectGroup('Trait attack buffs'); + expect(effectGroup).toBeDefined(); + if (effectGroup) { + const addedBuffIds = Array.from(effectGroup.effectTimelines.keys()); + expect(addedBuffIds.length).not.toBe(0); + expect( + addedBuffIds.every((buffId) => buffIdsToCheck.includes(buffId)) + ).toBe(true); + } }); it('is added, triggered by any weapon discharge', () => { @@ -955,11 +911,15 @@ describe('CombatSimulator', () => { }); const buffIdsToCheck = shiroTrait.attackBuffs.map((buff) => buff.id); - const addedBuffIds = Array.from(sut.traitAttackBuffTimelines.keys()); - expect(addedBuffIds.length).not.toBe(0); - expect( - addedBuffIds.every((buffId) => buffIdsToCheck.includes(buffId)) - ).toBe(true); + const effectGroup = sut.effectPool.getEffectGroup('Trait attack buffs'); + expect(effectGroup).toBeDefined(); + if (effectGroup) { + const addedBuffIds = Array.from(effectGroup.effectTimelines.keys()); + expect(addedBuffIds.length).not.toBe(0); + expect( + addedBuffIds.every((buffId) => buffIdsToCheck.includes(buffId)) + ).toBe(true); + } }); }); @@ -974,7 +934,10 @@ describe('CombatSimulator', () => { attackDefinition: weapon1.definition.normalAttacks[0], }); - expect(sut.traitMiscBuffTimelines.size).toBe(1); + expect( + sut.effectPool.getEffectGroup('Trait miscellaneous buffs') + ?.effectTimelines.size + ).toBe(1); }); it('is added, triggered by specific weapon normal attack', () => { @@ -995,7 +958,7 @@ describe('CombatSimulator', () => { attackDefinition: mingJingWeapon.definition.normalAttacks[0], }); - const buffTimeline = sut.traitMiscBuffTimelines.get( + const buffTimeline = sut.effectPool.getEffectTimeline( 'mingjing-trait-normal-attack' ); expect(buffTimeline).toBeDefined(); @@ -1040,15 +1003,9 @@ describe('CombatSimulator', () => { attackDefinition: yanmiaoWeapon.definition.normalAttacks[0], }); - expect(sut.traitMiscBuffTimelines.has(yanmiaoTrait1PhysBuff.id)).toBe( - true - ); - expect(sut.traitMiscBuffTimelines.has(yanmiaoTrait2PhysBuff.id)).toBe( - false - ); - expect(sut.traitMiscBuffTimelines.has(yanmiaoTrait3PhysBuff.id)).toBe( - false - ); + expect(sut.effectPool.hasEffect(yanmiaoTrait1PhysBuff.id)).toBe(true); + expect(sut.effectPool.hasEffect(yanmiaoTrait2PhysBuff.id)).toBe(false); + expect(sut.effectPool.hasEffect(yanmiaoTrait3PhysBuff.id)).toBe(false); // 2 Phys weapon team.weapon2 = new Weapon(weaponDefinitions.byId['Plotti']); @@ -1058,15 +1015,9 @@ describe('CombatSimulator', () => { attackDefinition: yanmiaoWeapon.definition.normalAttacks[0], }); - expect(sut2.traitMiscBuffTimelines.has(yanmiaoTrait1PhysBuff.id)).toBe( - false - ); - expect(sut2.traitMiscBuffTimelines.has(yanmiaoTrait2PhysBuff.id)).toBe( - true - ); - expect(sut2.traitMiscBuffTimelines.has(yanmiaoTrait3PhysBuff.id)).toBe( - false - ); + expect(sut2.effectPool.hasEffect(yanmiaoTrait1PhysBuff.id)).toBe(false); + expect(sut2.effectPool.hasEffect(yanmiaoTrait2PhysBuff.id)).toBe(true); + expect(sut2.effectPool.hasEffect(yanmiaoTrait3PhysBuff.id)).toBe(false); }); }); }); @@ -1115,7 +1066,7 @@ describe('CombatSimulator', () => { attackDefinition: weapon1.definition.skills[0], }); - const effectEvent = sut.weaponEffectsTimelines.get( + const effectEvent = sut.effectPool.getEffectTimeline( 'brevey-effect-pact-amplification' )?.lastEvent; expect(effectEvent).toBeDefined(); diff --git a/src/models/v4/attacks/attack-command.ts b/src/models/v4/attacks/attack-command.ts new file mode 100644 index 00000000..f8020aac --- /dev/null +++ b/src/models/v4/attacks/attack-command.ts @@ -0,0 +1,3 @@ +import type { Attack } from './attack'; + +export type AttackCommand = Pick; diff --git a/src/models/v4/attack-definition.ts b/src/models/v4/attacks/attack-definition.ts similarity index 88% rename from src/models/v4/attack-definition.ts rename to src/models/v4/attacks/attack-definition.ts index e1e40a32..40fdf760 100644 --- a/src/models/v4/attack-definition.ts +++ b/src/models/v4/attacks/attack-definition.ts @@ -1,5 +1,5 @@ -import type { AttackType } from '../../constants/attack-type'; -import type { WeaponElementalType } from '../../constants/elemental-type'; +import type { AttackType } from '../../../constants/attack-type'; +import type { WeaponElementalType } from '../../../constants/elemental-type'; export interface AttackDefinition { id: string; diff --git a/src/models/v4/attack.ts b/src/models/v4/attacks/attack.ts similarity index 76% rename from src/models/v4/attack.ts rename to src/models/v4/attacks/attack.ts index 0dc41a13..ef0dc3a8 100644 --- a/src/models/v4/attack.ts +++ b/src/models/v4/attacks/attack.ts @@ -1,5 +1,5 @@ -import type { WeaponElementalType } from '../../constants/elemental-type'; -import type { Weapon } from '../weapon'; +import type { WeaponElementalType } from '../../../constants/elemental-type'; +import type { Weapon } from '../../weapon'; import type { AttackDefinition } from './attack-definition'; export interface Attack { diff --git a/src/models/v4/buffs/attack-buff-definition.ts b/src/models/v4/buffs/attack-buff-definition.ts index ce9aad6e..0209bb91 100644 --- a/src/models/v4/buffs/attack-buff-definition.ts +++ b/src/models/v4/buffs/attack-buff-definition.ts @@ -1,5 +1,5 @@ import type { WeaponElementalType } from '../../../constants/elemental-type'; -import type { EffectDefinition } from '../effect-definition'; +import type { EffectDefinition } from '../effects/effect-definition'; export interface AttackBuffDefinition extends EffectDefinition { value: number; diff --git a/src/models/v4/buffs/common-weapon-attack-buff-definition.ts b/src/models/v4/buffs/common-weapon-attack-buff-definition.ts new file mode 100644 index 00000000..c38392a1 --- /dev/null +++ b/src/models/v4/buffs/common-weapon-attack-buff-definition.ts @@ -0,0 +1,6 @@ +import type { CommonWeaponAttackBuffId } from '../../../constants/common-weapon-attack-buffs'; +import type { AttackBuffDefinition } from './attack-buff-definition'; + +export interface CommonWeaponAttackBuffDefinition extends AttackBuffDefinition { + id: CommonWeaponAttackBuffId; +} diff --git a/src/models/v4/buffs/damage-buff-definition.ts b/src/models/v4/buffs/damage-buff-definition.ts index c91dd8fa..76049e3a 100644 --- a/src/models/v4/buffs/damage-buff-definition.ts +++ b/src/models/v4/buffs/damage-buff-definition.ts @@ -1,5 +1,5 @@ import type { WeaponElementalType } from '../../../constants/elemental-type'; -import type { EffectDefinition } from '../effect-definition'; +import type { EffectDefinition } from '../effects/effect-definition'; export interface DamageBuffDefinition extends EffectDefinition { value: number; diff --git a/src/models/v4/buffs/miscellaneous-buff-definition.ts b/src/models/v4/buffs/miscellaneous-buff-definition.ts index bc944cdd..c918667c 100644 --- a/src/models/v4/buffs/miscellaneous-buff-definition.ts +++ b/src/models/v4/buffs/miscellaneous-buff-definition.ts @@ -1,6 +1,6 @@ import type { WeaponElementalType } from '../../../constants/elemental-type'; import type { WeaponName } from '../../../constants/weapon-definitions'; -import type { EffectDefinition } from '../effect-definition'; +import type { EffectDefinition } from '../effects/effect-definition'; export interface MiscellaneousBuffDefinition extends EffectDefinition { // Order buffs from most specific to least specific. Check in this order for efficiency diff --git a/src/models/v4/combat-simulator.ts b/src/models/v4/combat-simulator.ts index 597d7fa2..b4b0cf86 100644 --- a/src/models/v4/combat-simulator.ts +++ b/src/models/v4/combat-simulator.ts @@ -1,59 +1,42 @@ -import BigNumber from 'bignumber.js'; -import groupBy from 'lodash.groupby'; - -import { commonWeaponAttackBuffs } from '../../constants/common-weapon-attack-buffs'; -import { commonWeaponDamageBuffs } from '../../constants/common-weapon-damage-buffs'; import type { Loadout } from '../loadout'; import type { Weapon } from '../weapon'; -import type { Attack } from './attack'; -import type { EffectDefinition } from './effect-definition'; +import type { AttackCommand } from './attacks/attack-command'; +import { EffectPool } from './effects/effect-pool'; import type { Relics } from './relics'; -import { AttackEvent } from './timeline/attack-event'; -import { AttackTimeline } from './timeline/attack-timeline'; -import { ChargeTimeline } from './timeline/charge-timeline'; -import { EffectEvent } from './timeline/effect-event'; -import { EffectTimeline } from './timeline/effect-timeline'; +import { AttackEvent } from './timelines/attack-event'; +import { AttackTimeline } from './timelines/attack-timeline'; +import { ChargeTimeline } from './timelines/charge-timeline'; export class CombatSimulator { public readonly attackTimelines = new Map(); - - public readonly weaponAttackBuffTimelines = new Map(); - public readonly weaponDamageBuffTimelines = new Map(); - public readonly relicDamageBuffTimelines = new Map(); - public readonly traitAttackBuffTimelines = new Map(); - public readonly traitDamageBuffTimelines = new Map(); - public readonly traitMiscBuffTimelines = new Map(); - - public readonly chargeTimeline = new ChargeTimeline(); - public readonly weaponEffectsTimelines = new Map(); + public readonly chargeTimeline: ChargeTimeline; + public readonly effectPool: EffectPool; private _activeWeapon: Weapon | undefined; /** The previous weapon before switching to the current active weapon */ private _previousWeapon: Weapon | undefined; - /** Registered effect definitions will be checked whenever an attack happens. An effect event will be added to the specified timeline if the conditions defined in the effect definition are met */ - private registeredEffects: { - effectDefinitions: EffectDefinition[]; - timelineGroupToAddTo: Map; - }[] = []; - public constructor( public readonly combatDuration: number, private readonly loadout: Loadout, private readonly relics: Relics ) { loadout.team.weapons.forEach((weapon) => { - this.attackTimelines.set(weapon, new AttackTimeline()); + this.attackTimelines.set( + weapon, + new AttackTimeline(weapon.definition.displayName, combatDuration) + ); }); - this.registerEffects(); + this.chargeTimeline = new ChargeTimeline('Weapon charge', combatDuration); + this.effectPool = new EffectPool(combatDuration, loadout, relics); } public get activeWeapon(): Weapon | undefined { return this._activeWeapon; } - public get availableAttacks(): Pick[] { + public get availableAttacks(): AttackCommand[] { const allAttacks = this.loadout.team.weapons.flatMap((weapon) => { const { definition: { normalAttacks, dodgeAttacks, skills, discharge }, @@ -103,10 +86,8 @@ export class CombatSimulator { : 0; } - public performAttack({ - weapon, - attackDefinition, - }: Pick) { + public performAttack(attackCommand: AttackCommand) { + const { weapon, attackDefinition } = attackCommand; if ( !this.availableAttacks.find( (availableAttack) => @@ -131,8 +112,7 @@ export class CombatSimulator { const attackEvent = new AttackEvent( nextEarliestAttackStartTime, - weapon, - attackDefinition + attackCommand ); if ( @@ -144,14 +124,9 @@ export class CombatSimulator { attackTimeline.addEvent(attackEvent); - // Register all events first at the start of combat - if (attackEvent.startTime === 0) { - this.registerEffects(); - } - this.adjustCharge(attackEvent); - this.triggerRegisteredEffectsIfApplicable(attackEvent); + this.effectPool.triggerEffects(attackEvent); } private adjustCharge(attackEvent: AttackEvent) { @@ -164,319 +139,4 @@ export class CombatSimulator { ); } } - - private triggerRegisteredEffectsIfApplicable(attackEvent: AttackEvent) { - this.registeredEffects.forEach( - ({ effectDefinitions, timelineGroupToAddTo }) => { - effectDefinitions.forEach((effectDefinition) => { - this.addEffectIfApplicable( - effectDefinition, - timelineGroupToAddTo, - attackEvent - ); - }); - } - ); - } - - private registerEffects() { - const { - loadout: { - team: { weapons }, - simulacrumTrait, - }, - relics, - } = this; - - // NOTE: Add these in least likely to be depended on from another effect to most likely. This pretty much means order these in most broad to most specific buffs. This is to avoid an effect that is supposed to depend on another to be triggered at the exact same time. This can be improved upon in the future (perhaps add "ticks"?) but it'll do for now (right now a "tick" = when a new attack occurs) - this.registeredEffects = [ - { - effectDefinitions: relics.passiveRelicBuffs, - timelineGroupToAddTo: this.relicDamageBuffTimelines, - }, - { - effectDefinitions: simulacrumTrait?.attackBuffs ?? [], - timelineGroupToAddTo: this.traitAttackBuffTimelines, - }, - { - effectDefinitions: simulacrumTrait?.damageBuffs ?? [], - timelineGroupToAddTo: this.traitDamageBuffTimelines, - }, - { - effectDefinitions: simulacrumTrait?.miscellaneousBuffs ?? [], - timelineGroupToAddTo: this.traitMiscBuffTimelines, - }, - { - effectDefinitions: weapons.flatMap((weapon) => - weapon.definition.commonAttackBuffs.map( - (buffId) => commonWeaponAttackBuffs[buffId] - ) - ), - timelineGroupToAddTo: this.weaponAttackBuffTimelines, - }, - { - effectDefinitions: weapons.flatMap((weapon) => - weapon.definition.commonDamageBuffs.map( - (buffId) => commonWeaponDamageBuffs[buffId] - ) - ), - timelineGroupToAddTo: this.weaponDamageBuffTimelines, - }, - { - effectDefinitions: weapons.flatMap( - (weapon) => weapon.definition.attackBuffs - ), - timelineGroupToAddTo: this.weaponAttackBuffTimelines, - }, - { - effectDefinitions: weapons.flatMap( - (weapon) => weapon.definition.damageBuffs - ), - timelineGroupToAddTo: this.weaponDamageBuffTimelines, - }, - { - effectDefinitions: weapons.flatMap( - (weapon) => weapon.definition.effects - ), - timelineGroupToAddTo: this.weaponEffectsTimelines, - }, - ]; - } - - private addEffectIfApplicable( - effectDefinition: EffectDefinition, - timelineGroup: Map, - attackEvent: AttackEvent - ) { - if (!this.hasEffectMetRequirements(effectDefinition, attackEvent.startTime)) - return; - if (!this.shouldTriggerEffect(effectDefinition, attackEvent)) return; - - const effectTimePeriod = this.determineEffectTimePeriod( - effectDefinition, - attackEvent - ); - if (!effectTimePeriod) return; - - const { id, maxStacks } = effectDefinition; - - if (!timelineGroup.has(id)) { - timelineGroup.set(id, new EffectTimeline()); - } - - timelineGroup - .get(id) - ?.addEvent( - new EffectEvent( - effectTimePeriod.startTime, - effectTimePeriod.duration, - effectDefinition, - maxStacks - ) - ); - } - - private hasEffectMetRequirements( - effect: EffectDefinition, - time: number - ): boolean { - const { requirements } = effect; - if (!requirements) return true; - - const { weapons, weaponNames, weaponResonance, weaponElementalTypes } = - this.loadout.team; - - // Check requirements from most specific to least specific for efficiency - - if ( - requirements.activeEffect && - !this.isEffectActive(requirements.activeEffect, time) - ) - return false; - - if ( - requirements.weaponInTeam && - !weaponNames.includes(requirements.weaponInTeam) - ) - return false; - - if ( - requirements.weaponResonance && - weaponResonance !== requirements.weaponResonance - ) - return false; - - const elementalTypeWeaponRequirement = - requirements.elementalTypeWeaponsInTeam; - if (elementalTypeWeaponRequirement) { - const numOfWeaponsOfElementalType = weaponElementalTypes.filter( - (x) => x === elementalTypeWeaponRequirement.elementalType - ).length; - - if ( - numOfWeaponsOfElementalType !== - elementalTypeWeaponRequirement.numOfWeapons - ) - return false; - } - - const notElementalTypeWeaponRequirement = - requirements.notElementalTypeWeaponsInTeam; - if (notElementalTypeWeaponRequirement) { - const numOfNotElementalTypeWeapons = weapons.filter( - (weapon) => - !weapon.definition.resonanceElements.includes( - notElementalTypeWeaponRequirement.notElementalType - ) - ).length; - - if ( - numOfNotElementalTypeWeapons !== - notElementalTypeWeaponRequirement.numOfWeapons - ) - return false; - } - - if (requirements.numOfDifferentElementalTypesInTeam) { - const numOfDifferentElementalTypes = Object.keys( - groupBy(weaponElementalTypes) - ).length; - - if ( - numOfDifferentElementalTypes !== - requirements.numOfDifferentElementalTypesInTeam - ) - return false; - } - - return true; - } - - private shouldTriggerEffect( - effectDefinition: EffectDefinition, - attackEvent: AttackEvent - ): boolean { - const { triggeredBy } = effectDefinition; - - const { - attackDefinition: { id: attackId, type: attackType }, - weapon: { - definition: { - id: weaponId, - type: weaponType, - resonanceElements: weaponElementalTypes, - }, - }, - } = attackEvent; - - // Check triggers from least specific to most specific for efficiency - - if (triggeredBy.combatStart && attackEvent.startTime === 0) return true; - - if (triggeredBy.skillOfAnyWeapon && attackType === 'skill') return true; - - if (triggeredBy.dischargeOfAnyWeapon && attackType === 'discharge') - return true; - - if ( - triggeredBy.skillOfWeaponType && - attackType === 'skill' && - weaponType === triggeredBy.skillOfWeaponType - ) - return true; - - if ( - triggeredBy.dischargeOfWeaponType && - attackType === 'discharge' && - weaponType === triggeredBy.dischargeOfWeaponType - ) - return true; - - // TODO: need to check if dual element weapons trigger this - if ( - triggeredBy.skillOfElementalType && - attackType === 'skill' && - weaponElementalTypes.includes(triggeredBy.skillOfElementalType) - ) - return true; - - // TODO: need to check if dual element weapons trigger this - if ( - triggeredBy.dischargeOfElementalType && - attackType === 'discharge' && - weaponElementalTypes.includes(triggeredBy.dischargeOfElementalType) - ) - return true; - - if (triggeredBy.notActiveWeapon && weaponId !== triggeredBy.notActiveWeapon) - return true; - - if (triggeredBy.activeWeapon && weaponId === triggeredBy.activeWeapon) - return true; - - if ( - triggeredBy.weaponAttacks && - triggeredBy.weaponAttacks.includes(attackId) - ) - return true; - - return false; - } - - private determineEffectTimePeriod( - effect: EffectDefinition, - attackEvent: AttackEvent - ): { startTime: number; duration: number } | undefined { - const { - duration: { - value, - followActiveWeapon, - applyToEndSegmentOfCombat, - untilCombatEnd, - }, - } = effect; - - if (value) { - return { startTime: attackEvent.startTime, duration: value }; - } - if (followActiveWeapon) { - return { - startTime: attackEvent.startTime, - duration: attackEvent.duration, - }; - } - if (applyToEndSegmentOfCombat) { - const duration = BigNumber(this.combatDuration) - .times(applyToEndSegmentOfCombat) - .toNumber(); - const startTime = BigNumber(this.combatDuration) - .minus(duration) - .toNumber(); - - return { startTime, duration }; - } - if (untilCombatEnd) { - return { - startTime: attackEvent.startTime, - duration: BigNumber(this.combatDuration) - .minus(attackEvent.startTime) - .toNumber(), - }; - } - - return undefined; - } - - /** Check if an effect at the given time by checking through the registered effects */ - private isEffectActive(effectId: string, time: number) { - // Assume there will only be one timeline holding the effect, not multiple - for (const { timelineGroupToAddTo } of this.registeredEffects) { - const eventTimeline = timelineGroupToAddTo.get(effectId); - if (eventTimeline) { - return eventTimeline.getEventsOverlapping(time, time).length !== 0; - } - } - - return false; - } } diff --git a/src/models/v4/common-weapon-attack-buff-definition.ts b/src/models/v4/common-weapon-attack-buff-definition.ts deleted file mode 100644 index 06740e70..00000000 --- a/src/models/v4/common-weapon-attack-buff-definition.ts +++ /dev/null @@ -1,6 +0,0 @@ -import type { CommonWeaponAttackBuffId } from '../../constants/common-weapon-attack-buffs'; -import type { AttackBuffDefinition } from './buffs/attack-buff-definition'; - -export interface CommonWeaponAttackBuffDefinition extends AttackBuffDefinition { - id: CommonWeaponAttackBuffId; -} diff --git a/src/models/v4/effect-definition.ts b/src/models/v4/effects/effect-definition.ts similarity index 91% rename from src/models/v4/effect-definition.ts rename to src/models/v4/effects/effect-definition.ts index c2bd1d7b..91f8492d 100644 --- a/src/models/v4/effect-definition.ts +++ b/src/models/v4/effects/effect-definition.ts @@ -1,9 +1,9 @@ -import type { WeaponElementalType } from '../../constants/elemental-type'; +import type { WeaponElementalType } from '../../../constants/elemental-type'; import type { WeaponName, WeaponType, -} from '../../constants/weapon-definitions'; -import type { WeaponResonance } from '../../constants/weapon-resonance'; +} from '../../../constants/weapon-definitions'; +import type { WeaponResonance } from '../../../constants/weapon-resonance'; export interface EffectDefinition { id: string; diff --git a/src/models/v4/effects/effect-evaluator.ts b/src/models/v4/effects/effect-evaluator.ts new file mode 100644 index 00000000..50afb317 --- /dev/null +++ b/src/models/v4/effects/effect-evaluator.ts @@ -0,0 +1,163 @@ +import groupBy from 'lodash.groupby'; + +import type { Team } from '../../team'; +import type { AttackEvent } from '../timelines/attack-event'; +import type { EffectDefinition } from './effect-definition'; +import type { EffectPool } from './effect-pool'; + +export class EffectEvaluator { + public constructor( + private readonly effectPool: EffectPool, + private readonly team: Team + ) {} + + public canEffectTrigger( + effectDefinition: EffectDefinition, + attackEvent: AttackEvent + ): boolean { + const { triggeredBy } = effectDefinition; + + const { + attackDefinition: { id: attackId, type: attackType }, + weapon: { + definition: { + id: weaponId, + type: weaponType, + resonanceElements: weaponElementalTypes, + }, + }, + } = attackEvent; + + // Check triggers from least specific to most specific for efficiency + + if (triggeredBy.combatStart && attackEvent.startTime === 0) return true; + + if (triggeredBy.skillOfAnyWeapon && attackType === 'skill') return true; + + if (triggeredBy.dischargeOfAnyWeapon && attackType === 'discharge') + return true; + + if ( + triggeredBy.skillOfWeaponType && + attackType === 'skill' && + weaponType === triggeredBy.skillOfWeaponType + ) + return true; + + if ( + triggeredBy.dischargeOfWeaponType && + attackType === 'discharge' && + weaponType === triggeredBy.dischargeOfWeaponType + ) + return true; + + // TODO: need to check if dual element weapons trigger this + if ( + triggeredBy.skillOfElementalType && + attackType === 'skill' && + weaponElementalTypes.includes(triggeredBy.skillOfElementalType) + ) + return true; + + // TODO: need to check if dual element weapons trigger this + if ( + triggeredBy.dischargeOfElementalType && + attackType === 'discharge' && + weaponElementalTypes.includes(triggeredBy.dischargeOfElementalType) + ) + return true; + + if (triggeredBy.notActiveWeapon && weaponId !== triggeredBy.notActiveWeapon) + return true; + + if (triggeredBy.activeWeapon && weaponId === triggeredBy.activeWeapon) + return true; + + if ( + triggeredBy.weaponAttacks && + triggeredBy.weaponAttacks.includes(attackId) + ) + return true; + + return false; + } + + public hasEffectMetRequirements( + effectDefinition: EffectDefinition, + attackEvent: AttackEvent + ): boolean { + const { requirements } = effectDefinition; + if (!requirements) return true; + + const { weapons, weaponNames, weaponResonance, weaponElementalTypes } = + this.team; + + // Check requirements from most specific to least specific for efficiency + + if ( + requirements.activeEffect && + !this.effectPool.isEffectActive( + requirements.activeEffect, + attackEvent.startTime + ) + ) + return false; + + if ( + requirements.weaponInTeam && + !weaponNames.includes(requirements.weaponInTeam) + ) + return false; + + if ( + requirements.weaponResonance && + weaponResonance !== requirements.weaponResonance + ) + return false; + + const elementalTypeWeaponRequirement = + requirements.elementalTypeWeaponsInTeam; + if (elementalTypeWeaponRequirement) { + const numOfWeaponsOfElementalType = weaponElementalTypes.filter( + (x) => x === elementalTypeWeaponRequirement.elementalType + ).length; + + if ( + numOfWeaponsOfElementalType !== + elementalTypeWeaponRequirement.numOfWeapons + ) + return false; + } + + const notElementalTypeWeaponRequirement = + requirements.notElementalTypeWeaponsInTeam; + if (notElementalTypeWeaponRequirement) { + const numOfNotElementalTypeWeapons = weapons.filter( + (weapon) => + !weapon.definition.resonanceElements.includes( + notElementalTypeWeaponRequirement.notElementalType + ) + ).length; + + if ( + numOfNotElementalTypeWeapons !== + notElementalTypeWeaponRequirement.numOfWeapons + ) + return false; + } + + if (requirements.numOfDifferentElementalTypesInTeam) { + const numOfDifferentElementalTypes = Object.keys( + groupBy(weaponElementalTypes) + ).length; + + if ( + numOfDifferentElementalTypes !== + requirements.numOfDifferentElementalTypesInTeam + ) + return false; + } + + return true; + } +} diff --git a/src/models/v4/effects/effect-group.ts b/src/models/v4/effects/effect-group.ts new file mode 100644 index 00000000..1dbc7b13 --- /dev/null +++ b/src/models/v4/effects/effect-group.ts @@ -0,0 +1,101 @@ +import BigNumber from 'bignumber.js'; + +import type { AttackEvent } from '../timelines/attack-event'; +import { EffectEvent } from '../timelines/effect-event'; +import { EffectTimeline } from '../timelines/effect-timeline'; +import type { EffectDefinition } from './effect-definition'; +import type { EffectEvaluator } from './effect-evaluator'; + +export class EffectGroup { + public readonly effectTimelines = new Map(); + + public constructor( + public label: string, + private readonly combatDuration: number, + private readonly effectDefinitions: EffectDefinition[], + private readonly effectEvaluator: EffectEvaluator + ) {} + + public triggerEffects(attackEvent: AttackEvent) { + for (const effectDefinition of this.effectDefinitions) { + if ( + !this.effectEvaluator.hasEffectMetRequirements( + effectDefinition, + attackEvent + ) || + !this.effectEvaluator.canEffectTrigger(effectDefinition, attackEvent) + ) + continue; + + const effectTimePeriod = this.determineEffectTimePeriod( + effectDefinition, + attackEvent + ); + if (!effectTimePeriod) continue; + + const { id, displayName, maxStacks } = effectDefinition; + + if (!this.effectTimelines.has(id)) { + this.effectTimelines.set( + id, + new EffectTimeline(displayName, this.combatDuration) + ); + } + + this.effectTimelines + .get(id) + ?.addEvent( + new EffectEvent( + effectTimePeriod.startTime, + effectTimePeriod.duration, + effectDefinition, + maxStacks + ) + ); + } + } + + public determineEffectTimePeriod( + effectDefinition: EffectDefinition, + attackEvent: AttackEvent + ): { startTime: number; duration: number } | undefined { + const { + duration: { + value, + followActiveWeapon, + applyToEndSegmentOfCombat, + untilCombatEnd, + }, + } = effectDefinition; + + if (value) { + return { startTime: attackEvent.startTime, duration: value }; + } + if (followActiveWeapon) { + return { + startTime: attackEvent.startTime, + duration: attackEvent.duration, + }; + } + if (applyToEndSegmentOfCombat) { + const duration = BigNumber(this.combatDuration) + .times(applyToEndSegmentOfCombat) + .toNumber(); + const startTime = BigNumber(this.combatDuration) + .minus(duration) + .toNumber(); + + return { startTime, duration }; + } + if (untilCombatEnd) { + return { + startTime: attackEvent.startTime, + duration: BigNumber(this.combatDuration) + .minus(attackEvent.startTime) + .toNumber(), + }; + } + + return undefined; + } +} diff --git a/src/models/v4/effects/effect-pool.ts b/src/models/v4/effects/effect-pool.ts new file mode 100644 index 00000000..5d6d768a --- /dev/null +++ b/src/models/v4/effects/effect-pool.ts @@ -0,0 +1,121 @@ +import { commonWeaponAttackBuffs } from '../../../constants/common-weapon-attack-buffs'; +import { commonWeaponDamageBuffs } from '../../../constants/common-weapon-damage-buffs'; +import type { Loadout } from '../../loadout'; +import type { AttackBuffDefinition } from '../buffs/attack-buff-definition'; +import type { DamageBuffDefinition } from '../buffs/damage-buff-definition'; +import type { Relics } from '../relics'; +import type { AttackEvent } from '../timelines/attack-event'; +import type { EffectDefinition } from './effect-definition'; +import { EffectEvaluator } from './effect-evaluator'; +import { EffectGroup } from './effect-group'; + +type EffectGroupLabel = + | 'Weapon attack buffs' + | 'Weapon damage buffs' + | 'Weapon effects' + | 'Trait attack buffs' + | 'Trait damage buffs' + | 'Trait miscellaneous buffs' + | 'Relic passive buffs'; + +export class EffectPool { + public readonly effectGroups: EffectGroup[] = []; + + private readonly effectEvaluator: EffectEvaluator; + + public constructor( + private readonly combatDuration: number, + loadout: Loadout, + relics: Relics + ) { + const { + team: { weapons }, + simulacrumTrait, + } = loadout; + + this.effectEvaluator = new EffectEvaluator(this, loadout.team); + + const weaponAttackBuffDefinitions = weapons + .flatMap((weapon) => + weapon.definition.commonAttackBuffs.map( + (buffId) => commonWeaponAttackBuffs[buffId] + ) + ) + .concat(weapons.flatMap((weapon) => weapon.definition.attackBuffs)); + this.addEffectGroup('Weapon attack buffs', weaponAttackBuffDefinitions); + + const weaponDamageBuffDefinitions = weapons + .flatMap((weapon) => + weapon.definition.commonDamageBuffs.map( + (buffId) => commonWeaponDamageBuffs[buffId] + ) + ) + .concat(weapons.flatMap((weapon) => weapon.definition.damageBuffs)); + this.addEffectGroup('Weapon damage buffs', weaponDamageBuffDefinitions); + + const weaponEffectDefinitions = weapons.flatMap( + (weapon) => weapon.definition.effects + ); + this.addEffectGroup('Weapon effects', weaponEffectDefinitions); + + this.addEffectGroup( + 'Trait attack buffs', + simulacrumTrait?.attackBuffs ?? [] + ); + this.addEffectGroup( + 'Trait damage buffs', + simulacrumTrait?.damageBuffs ?? [] + ); + this.addEffectGroup( + 'Trait miscellaneous buffs', + simulacrumTrait?.miscellaneousBuffs ?? [] + ); + this.addEffectGroup('Relic passive buffs', relics.passiveRelicBuffs); + } + + public hasEffect(effectId: string) { + return !!this.getEffectTimeline(effectId); + } + + public getEffectTimeline(effectId: string) { + for (const effectGroup of this.effectGroups) { + const effectTimeline = effectGroup.effectTimelines.get(effectId); + if (effectTimeline) { + return effectTimeline; + } + } + } + + public triggerEffects(attackEvent: AttackEvent) { + for (const effectGroup of this.effectGroups) { + effectGroup.triggerEffects(attackEvent); + } + } + + /** Check if an effect at the given time by checking through all effect timelines */ + public isEffectActive(effectId: string, time: number) { + // Assume there will only be one timeline holding the effect, not multiple + const effectTimeline = this.getEffectTimeline(effectId); + if (!effectTimeline) return false; + + return effectTimeline.getEventsOverlapping(time, time).length !== 0; + } + + public getEffectGroup(label: EffectGroupLabel) { + return this.effectGroups.find((effectGroup) => effectGroup.label === label); + } + + private addEffectGroup( + label: EffectGroupLabel, + effectDefinitions: EffectDefinition[] + ) { + this.effectGroups.push( + new EffectGroup( + label, + this.combatDuration, + effectDefinitions, + this.effectEvaluator + ) + ); + } +} diff --git a/src/models/v4/timeline/__tests__/attack-timeline.test.ts b/src/models/v4/timelines/__tests__/attack-timeline.test.ts similarity index 56% rename from src/models/v4/timeline/__tests__/attack-timeline.test.ts rename to src/models/v4/timelines/__tests__/attack-timeline.test.ts index 0ffaf720..e9fc2e04 100644 --- a/src/models/v4/timeline/__tests__/attack-timeline.test.ts +++ b/src/models/v4/timelines/__tests__/attack-timeline.test.ts @@ -1,29 +1,32 @@ -import type { Attack } from '../../attack'; +import type { AttackCommand } from '../../attacks/attack-command'; import { AttackEvent } from '../attack-event'; import { AttackTimeline } from '../attack-timeline'; describe('Attack timeline', () => { - const mockAttack = { + const mockAttackCommand = { weapon: {}, attackDefinition: { duration: 7, }, - } as Attack; + } as AttackCommand; + + const timelineName = ''; + const timelineDuration = 100; it('returns the next valid start time', () => { - const sut = new AttackTimeline(); + const sut = new AttackTimeline(timelineName, timelineDuration); expect(sut.nextEarliestStartTime).toBe(0); - sut.addEvent(new AttackEvent(sut.nextEarliestStartTime, mockAttack)); + sut.addEvent(new AttackEvent(sut.nextEarliestStartTime, mockAttackCommand)); expect(sut.nextEarliestStartTime).toBe(7); }); it('throws an error when trying to add a new event with a start time earlier than the latest event', () => { - const sut = new AttackTimeline(); - sut.addEvent(new AttackEvent(5, mockAttack)); + const sut = new AttackTimeline(timelineName, timelineDuration); + sut.addEvent(new AttackEvent(5, mockAttackCommand)); expect(() => { - sut.addEvent(new AttackEvent(3, mockAttack)); + sut.addEvent(new AttackEvent(3, mockAttackCommand)); }).toThrow(); }); }); diff --git a/src/models/v4/timeline/__tests__/charge-timeline.test.ts b/src/models/v4/timelines/__tests__/charge-timeline.test.ts similarity index 76% rename from src/models/v4/timeline/__tests__/charge-timeline.test.ts rename to src/models/v4/timelines/__tests__/charge-timeline.test.ts index d70565c2..f9112038 100644 --- a/src/models/v4/timeline/__tests__/charge-timeline.test.ts +++ b/src/models/v4/timelines/__tests__/charge-timeline.test.ts @@ -1,17 +1,20 @@ import { maxCharge } from '../../../../constants/combat'; import { ChargeTimeline } from '../charge-timeline'; +const timelineDuration = 10000; +const timelineName = ''; + describe('Charge timeline', () => { describe('adding charge', () => { it('adds the charge amount to the cumulated charge amount', () => { - const sut = new ChargeTimeline(); + const sut = new ChargeTimeline(timelineName, timelineDuration); sut.addCharge(100, 1000); sut.addCharge(200, 2000); expect(sut.cumulatedCharge).toBe(300); }); it('cumulated charge cannot be over the max amount', () => { - const sut = new ChargeTimeline(); + const sut = new ChargeTimeline(timelineName, timelineDuration); sut.addCharge(1800, 1000); sut.addCharge(500, 2000); expect(sut.cumulatedCharge).toBe(maxCharge); @@ -20,14 +23,14 @@ describe('Charge timeline', () => { describe('deducting a full charge', () => { it('deducts the full charge amount from the cumulated charge amount', () => { - const sut = new ChargeTimeline(); + const sut = new ChargeTimeline(timelineName, timelineDuration); sut.addCharge(1300, 1000); sut.deductOneFullCharge(2000); expect(sut.cumulatedCharge).toBe(300); }); it('throws an error if there is not enough charge to deduct', () => { - const sut = new ChargeTimeline(); + const sut = new ChargeTimeline(timelineName, timelineDuration); sut.addCharge(300, 1000); expect(() => { sut.deductOneFullCharge(2000); @@ -36,7 +39,7 @@ describe('Charge timeline', () => { }); it('returns whether there is at least one full charge', () => { - const sut = new ChargeTimeline(); + const sut = new ChargeTimeline(timelineName, timelineDuration); sut.addCharge(500, 1000); expect(sut.hasFullCharge).toBe(false); diff --git a/src/models/v4/timeline/__tests__/effect-timeline.test.ts b/src/models/v4/timelines/__tests__/effect-timeline.test.ts similarity index 86% rename from src/models/v4/timeline/__tests__/effect-timeline.test.ts rename to src/models/v4/timelines/__tests__/effect-timeline.test.ts index fa7c58cd..7bdd2269 100644 --- a/src/models/v4/timeline/__tests__/effect-timeline.test.ts +++ b/src/models/v4/timelines/__tests__/effect-timeline.test.ts @@ -1,13 +1,15 @@ -import type { EffectDefinition } from '../../effect-definition'; +import type { EffectDefinition } from '../../effects/effect-definition'; import { EffectEvent } from '../effect-event'; import { EffectTimeline } from '../effect-timeline'; describe('Effect timeline', () => { + const timelineName = ''; + const timelineDuration = 100; const mockEffectDefinition = {} as EffectDefinition; describe('adding an event that overlaps with an existing one', () => { it('splits the events into smaller events with the correct stacks', () => { - const sut = new EffectTimeline(); + const sut = new EffectTimeline(timelineName, timelineDuration); sut.addEvent(new EffectEvent(0, 10, mockEffectDefinition, 2)); sut.addEvent(new EffectEvent(5, 10, mockEffectDefinition, 2)); @@ -30,7 +32,7 @@ describe('Effect timeline', () => { }); it('merges the two events by increasing the existing the duration of the existing event, if the resulting stacks of the two events are the same', () => { - const sut = new EffectTimeline(); + const sut = new EffectTimeline(timelineName, timelineDuration); sut.addEvent(new EffectEvent(0, 10, mockEffectDefinition, 1)); sut.addEvent(new EffectEvent(5, 10, mockEffectDefinition, 1)); @@ -39,7 +41,7 @@ describe('Effect timeline', () => { }); it("doesn't add a new event if the two events are of the exact same time period and the stack count cannot be increased further", () => { - const sut = new EffectTimeline(); + const sut = new EffectTimeline(timelineName, timelineDuration); sut.addEvent(new EffectEvent(0, 10, mockEffectDefinition, 1)); sut.addEvent(new EffectEvent(0, 10, mockEffectDefinition, 1)); @@ -50,7 +52,7 @@ describe('Effect timeline', () => { }); it("doesn't add a new event if the two events are of the exact same time period, but increase the stack count of the existing event if it can be increased further", () => { - const sut = new EffectTimeline(); + const sut = new EffectTimeline(timelineName, timelineDuration); sut.addEvent(new EffectEvent(0, 10, mockEffectDefinition, 3, 1)); sut.addEvent(new EffectEvent(0, 10, mockEffectDefinition, 3, 2)); @@ -62,7 +64,7 @@ describe('Effect timeline', () => { }); it('adds a new event when there are no previous events', () => { - const sut = new EffectTimeline(); + const sut = new EffectTimeline(timelineName, timelineDuration); sut.addEvent(new EffectEvent(0, 10, mockEffectDefinition)); expect(sut.events.length).toBe(1); @@ -71,7 +73,7 @@ describe('Effect timeline', () => { }); it('adds a new event onto the previous event (merges them) when the new event starts when the previous event ends and the two have the same number of stacks', () => { - const sut = new EffectTimeline(); + const sut = new EffectTimeline(timelineName, timelineDuration); sut.addEvent(new EffectEvent(0, 10, mockEffectDefinition, 2, 1)); sut.addEvent(new EffectEvent(10, 10, mockEffectDefinition, 2, 1)); @@ -81,7 +83,7 @@ describe('Effect timeline', () => { }); it('adds a new event when the new event starts when the previous event ends but the two do not have the same number of stacks', () => { - const sut = new EffectTimeline(); + const sut = new EffectTimeline(timelineName, timelineDuration); sut.addEvent(new EffectEvent(0, 10, mockEffectDefinition, 2, 1)); sut.addEvent(new EffectEvent(10, 10, mockEffectDefinition, 2, 2)); @@ -91,7 +93,7 @@ describe('Effect timeline', () => { }); it('does not add new event if the last event is still on cooldown', () => { - const sut = new EffectTimeline(); + const sut = new EffectTimeline(timelineName, timelineDuration); const eventWithCooldown = new EffectEvent(0, 10, mockEffectDefinition); eventWithCooldown.cooldown = 5; sut.addEvent(eventWithCooldown); diff --git a/src/models/v4/timeline/__tests__/timeline.test.ts b/src/models/v4/timelines/__tests__/timeline.test.ts similarity index 97% rename from src/models/v4/timeline/__tests__/timeline.test.ts rename to src/models/v4/timelines/__tests__/timeline.test.ts index ca1d8f55..3a92e485 100644 --- a/src/models/v4/timeline/__tests__/timeline.test.ts +++ b/src/models/v4/timelines/__tests__/timeline.test.ts @@ -3,7 +3,7 @@ import { TimelineEvent } from '../timeline-event'; describe('Timeline', () => { it('returns correct events overlapping with the specified start and end time', () => { - const sut = new Timeline(); + const sut = new Timeline('', 100); // Test against the period of 6 to 11. const event1 = new TimelineEvent(0, 5); // out diff --git a/src/models/v4/timeline/attack-event.ts b/src/models/v4/timelines/attack-event.ts similarity index 56% rename from src/models/v4/timeline/attack-event.ts rename to src/models/v4/timelines/attack-event.ts index 65b446fb..f05b6334 100644 --- a/src/models/v4/timeline/attack-event.ts +++ b/src/models/v4/timelines/attack-event.ts @@ -1,20 +1,22 @@ import type { WeaponElementalType } from '../../../constants/elemental-type'; import type { Weapon } from '../../weapon'; -import type { Attack } from '../attack'; -import type { AttackDefinition } from '../attack-definition'; +import type { Attack } from '../attacks/attack'; +import type { AttackCommand } from '../attacks/attack-command'; +import type { AttackDefinition } from '../attacks/attack-definition'; import { TimelineEvent } from './timeline-event'; export class AttackEvent extends TimelineEvent implements Attack { + public readonly weapon: Weapon; + public readonly attackDefinition: AttackDefinition; public elementalType: WeaponElementalType; public cooldown: number; - public constructor( - public startTime: number, - public readonly weapon: Weapon, - public readonly attackDefinition: AttackDefinition - ) { + public constructor(public startTime: number, attackCommand: AttackCommand) { + const { weapon, attackDefinition } = attackCommand; super(startTime, attackDefinition.duration); + this.weapon = weapon; + this.attackDefinition = attackDefinition; this.elementalType = attackDefinition.elementalType; this.cooldown = attackDefinition.cooldown; } diff --git a/src/models/v4/timeline/attack-timeline.ts b/src/models/v4/timelines/attack-timeline.ts similarity index 100% rename from src/models/v4/timeline/attack-timeline.ts rename to src/models/v4/timelines/attack-timeline.ts diff --git a/src/models/v4/timeline/charge-event.ts b/src/models/v4/timelines/charge-event.ts similarity index 100% rename from src/models/v4/timeline/charge-event.ts rename to src/models/v4/timelines/charge-event.ts diff --git a/src/models/v4/timeline/charge-timeline.ts b/src/models/v4/timelines/charge-timeline.ts similarity index 100% rename from src/models/v4/timeline/charge-timeline.ts rename to src/models/v4/timelines/charge-timeline.ts diff --git a/src/models/v4/timeline/effect-event.ts b/src/models/v4/timelines/effect-event.ts similarity index 89% rename from src/models/v4/timeline/effect-event.ts rename to src/models/v4/timelines/effect-event.ts index 338f61b0..78e12c95 100644 --- a/src/models/v4/timeline/effect-event.ts +++ b/src/models/v4/timelines/effect-event.ts @@ -1,4 +1,4 @@ -import type { EffectDefinition } from '../effect-definition'; +import type { EffectDefinition } from '../effects/effect-definition'; import { TimelineEvent } from './timeline-event'; export class EffectEvent extends TimelineEvent { diff --git a/src/models/v4/timeline/effect-timeline.ts b/src/models/v4/timelines/effect-timeline.ts similarity index 100% rename from src/models/v4/timeline/effect-timeline.ts rename to src/models/v4/timelines/effect-timeline.ts diff --git a/src/models/v4/timeline/timeline-event.ts b/src/models/v4/timelines/timeline-event.ts similarity index 86% rename from src/models/v4/timeline/timeline-event.ts rename to src/models/v4/timelines/timeline-event.ts index e3b4dfa2..9d0b2f4a 100644 --- a/src/models/v4/timeline/timeline-event.ts +++ b/src/models/v4/timelines/timeline-event.ts @@ -21,5 +21,3 @@ export class TimelineEvent { return ''; } } - -// export type ReadonlyTimelineEvent = Readonly>; diff --git a/src/models/v4/timeline/timeline.ts b/src/models/v4/timelines/timeline.ts similarity index 69% rename from src/models/v4/timeline/timeline.ts rename to src/models/v4/timelines/timeline.ts index 368359fc..b8a77470 100644 --- a/src/models/v4/timeline/timeline.ts +++ b/src/models/v4/timelines/timeline.ts @@ -3,6 +3,11 @@ import type { TimelineEvent } from './timeline-event'; export class Timeline { protected readonly _events: TEvent[] = []; + public constructor( + public displayName: string, + public readonly totalDuration: number + ) {} + public get events(): ReadonlyArray { return this._events; } @@ -12,6 +17,17 @@ export class Timeline { } public addEvent(event: TEvent) { + if (event.startTime >= this.totalDuration) { + throw new Error( + "Cannot add event that starts after the timeline's duration" + ); + } + + // Cut off event if it goes past the timeline duration + if (event.endTime > this.totalDuration) { + event.endTime = this.totalDuration; + } + this._events.push(event); } diff --git a/src/models/weapon-definition.ts b/src/models/weapon-definition.ts index f3331265..c6f0c2f2 100644 --- a/src/models/weapon-definition.ts +++ b/src/models/weapon-definition.ts @@ -8,10 +8,10 @@ import type { DodgeAttackDefinition, NormalAttackDefinition, SkillAttackDefinition, -} from './v4/attack-definition'; +} from './v4/attacks/attack-definition'; import type { AttackBuffDefinition } from './v4/buffs/attack-buff-definition'; import type { DamageBuffDefinition } from './v4/buffs/damage-buff-definition'; -import type { EffectDefinition } from './v4/effect-definition'; +import type { EffectDefinition } from './v4/effects/effect-definition'; import type { WeaponAttackPercentBuffDefinition, WeaponCritRateBuffDefinition,