diff --git a/packages/core/src/sims/cycle_settings.ts b/packages/core/src/sims/cycle_settings.ts index 29ff0305..e40ba134 100644 --- a/packages/core/src/sims/cycle_settings.ts +++ b/packages/core/src/sims/cycle_settings.ts @@ -1,7 +1,10 @@ +import {CutoffMode} from "./cycle_sim"; + export type CycleSettings = { totalTime: number, // not implemented yet cycles: number, which: 'totalTime' | 'cycles', - useAutos: boolean + useAutos: boolean, + cutoffMode: CutoffMode } diff --git a/packages/core/src/sims/cycle_sim.ts b/packages/core/src/sims/cycle_sim.ts index 8cd3a1fe..ab367f15 100644 --- a/packages/core/src/sims/cycle_sim.ts +++ b/packages/core/src/sims/cycle_sim.ts @@ -12,10 +12,10 @@ import { GcdAbility, OgcdAbility, PartyBuff, - SimResult, - SimSettings, + PostDmgUsedAbility, PreDmgUsedAbility, - PostDmgUsedAbility + SimResult, + SimSettings } from "./sim_types"; import {ComputedSetStats} from "@xivgear/xivmath/geartypes"; import { @@ -208,6 +208,38 @@ export class CycleContext { */ export type AbilityUseResult = 'full' | 'partial' | 'none'; + + +/* TODO: I thought of a better way to implement this. + + This can all be implemented post-hoc. + There is no need to intertwine any of this logic into the `use` method itself. + It can instead be done purely via finalized records. + This also goes for pro-rating - it can just all happen outside. + */ +/** + * Since it is unlikely that a GCD will end perfectly on the fight end time, we need to have strategies for adjusting + * DPS based on when the last action happens. + * + * 'prorate-gcd' is the previous default behavior. The final GCD will have its damage prorated based on how much of it + * fit into the fight time. + * + * 'prorate-application' is like 'prorate-gcd', but will use the application time rather than the GCD time. + * + * 'lax-gcd' allows the final GCD to fit in its entirely, but uses the start of the next GCD as the time basis for + * calculating DPS, i.e. if the fight time is 120, but your last GCD comes back up at 121.5, then the DPS will be + * (damage / 121.5) rather than (damage / 120). + * + * 'strict-gcd' works like 'lax-gcd', but drops incomplete GCDs entirely and uses the time of the last GCD that you + * could start, but not finish, within the timestamp. There is one sort-of exception - if a GCD's entire recast period + * would fit, but then extra oGCDs are clipped, then exactly *one* oGCD is allowed to push past the time limit. This + * should not be relied upon and may be fixed in the future. + */ +export type CutoffMode = 'prorate-gcd' + | 'prorate-application' + | 'lax-gcd' + | 'strict-gcd'; + /** * Base settings object for a cycle based sim */ @@ -241,6 +273,10 @@ export type MultiCycleSettings = { * Whether to hide dividers indicating the start and end of a cycle */ readonly hideCycleDividers?: boolean + /** + * How to deal with GCDs not lining up perfectly with the end of fight. + */ + readonly cutoffMode: CutoffMode; } export type CycleFunction = (cycle: CycleContext) => void @@ -405,6 +441,19 @@ export class CycleProcessor { */ readonly cdTracker: CooldownTracker; private _cdEnforcementMode: CooldownMode; + + /** + * The end-of-fight cutoff mode + */ + readonly cutoffMode: CutoffMode; + + /** + * If the cutoff mode is 'strict-gcd', this tracks what time basis we want to use as the real cutoff time. + * i.e. when does our last GCD end. This is only non-null once the fight is actually cut off. + * + * @private + */ + private hardCutoffGcdTime: number | null = null; /** * Controls the logic used to re-align cycles. Since cycles typically do not last exactly their desired time * (i.e. there is drift), you can control how it should re-align cycles when this happens. @@ -443,6 +492,7 @@ export class CycleProcessor { noIcon: true, potency: this.stats.jobStats.aaPotency }; + this.cutoffMode = settings.cutoffMode; } get cdEnforcementMode(): CooldownMode { @@ -560,6 +610,9 @@ export class CycleProcessor { if (!this.combatStarted) { return this.totalTime; } + if (this.isHardCutoff) { + return 0; + } return Math.max(0, this.totalTime - this.nextGcdTime); } @@ -708,6 +761,25 @@ export class CycleProcessor { } }) } + + computePartialRate(record: PostDmgUsedAbility): number { + switch (this.cutoffMode) { + case "prorate-gcd": + if (record.totalTimeTaken <= 0) { + return 1; + } + return Math.max(0, Math.min(1, (this.totalTime - record.usedAt) / record.totalTimeTaken)); + case "prorate-application": + if (record.appDelayFromStart <= 0) { + return 1; + } + return Math.max(0, Math.min(1, (this.totalTime - record.usedAt) / record.appDelayFromStart)); + case "lax-gcd": + case "strict-gcd": + return 1; + } + } + /** * A record of events, including special rows and such. */ @@ -716,7 +788,7 @@ export class CycleProcessor { return (this.postDamageRecords.map(record => { if (isAbilityUse(record)) { - const partialRate = record.totalTimeTaken > 0 ? Math.max(0, Math.min(1, (this.totalTime - record.usedAt) / record.totalTimeTaken)) : 1; + const partialRate = this.computePartialRate(record); const directDamage = multiplyFixed(record.directDamage, partialRate); const dot = record.dot; const dotDmg = dot ? multiplyIndependent(dot.damagePerTick, dot.actualTickCount) : fixedValue(0); @@ -743,6 +815,33 @@ export class CycleProcessor { })); } + get finalizedTimeBasis(): number { + switch (this.cutoffMode) { + case "prorate-gcd": + case "prorate-application": + // For these, we use either the current time, or the total allowed time. Pro-rating the final GCD is + // handled in `get finalizedRecords()` + return Math.min(this.totalTime, this.currentTime); + case "lax-gcd": + return this.nextGcdTime; + case "strict-gcd": { + const cutoffTime = this.hardCutoffGcdTime; + if (cutoffTime !== null) { + return cutoffTime; + } + // We can also have a situation where clipping oGCDs have pushed us over + const potentialMax = Math.max(...this.finalizedRecords.filter(isFinalizedAbilityUse) + .map(record => record.usedAt + record.original.totalTimeTaken)); + if (potentialMax > 9999999) { + return Math.min(this.totalTime, this.currentTime); + } + else { + return potentialMax; + } + } + } + } + private isGcd(ability: Ability): ability is GcdAbility { return ability.type === 'gcd'; } @@ -759,6 +858,10 @@ export class CycleProcessor { } } + get isHardCutoff(): boolean { + return this.hardCutoffGcdTime !== null; + } + /** * Use an ability * @@ -768,9 +871,27 @@ export class CycleProcessor { // noinspection AssignmentToFunctionParameterJS ability = this.processCombo(ability); const isGcd = this.isGcd(ability); - if (this.remainingGcdTime <= 0) { - // Already over time limit. Ignore completely. - return 'none'; + // if using a non-prorate mode, then allow oGCDs past the cutoff + const cutoffMode = this.cutoffMode; + if (isGcd || cutoffMode === 'prorate-gcd' || cutoffMode === 'prorate-application') { + if (this.remainingGcdTime <= 0 || this.isHardCutoff) { + // Already over time limit. Ignore completely. + return 'none'; + } + } + // if using strict-gcd mode, we also want to ignore oGCDs past the cutoff + else if (cutoffMode === 'strict-gcd') { + if (this.remainingTime <= 0 || this.isHardCutoff) { + return 'none'; + } + } + // This branch deals with the corner case where a long-cast GCD, or multiple clipped oGCDs + // push you over the edge and you try to use another oGCD. + // That oGCD shouldn't be considered "part of" the GCD like it would with a proper weave. + else if (cutoffMode === 'lax-gcd') { + if (this.remainingGcdTime <= 0 && this.nextGcdTime === this.currentTime) { + return 'none'; + } } // Since we might not be at the start of the next GCD yet (e.g. back-to-back instant GCDs), we need to do the // CD checking at the time when we expect to actually use this GCD. @@ -802,8 +923,22 @@ export class CycleProcessor { // noinspection AssignmentToFunctionParameterJS ability = this.beforeAbility(ability, preBuffs); const abilityGcd = isGcd ? (this.gcdTime(ability as GcdAbility, preCombinedEffects)) : 0; + if (this.isGcd(ability) && cutoffMode == 'strict-gcd') { + // If we would not be able to fit the GCD, flag it + if (this.remainingGcds(ability) < 1) { + this.hardCutoffGcdTime = this.currentTime; + return 'none'; + } + } this.markCd(ability, preCombinedEffects); const effectiveCastTime: number | null = ability.cast ? this.castTime(ability, preCombinedEffects) : null; + // Also check that we can fit the cast time, for long-casts + if (cutoffMode == 'strict-gcd' && effectiveCastTime > this.remainingTime) { + if (this.isGcd(ability)) { + this.hardCutoffGcdTime = this.currentTime; + return 'none'; + } + } const snapshotDelayFromStart = effectiveCastTime ? Math.max(0, effectiveCastTime - CAST_SNAPSHOT_PRE) : 0; const snapshotsAt = this.currentTime + snapshotDelayFromStart; // When this GCD will end (strictly in terms of GCD. e.g. a BLM spell where cast > recast will still take the cast time. This will be diff --git a/packages/core/src/sims/melee/nin/nin_lv100_sim.ts b/packages/core/src/sims/melee/nin/nin_lv100_sim.ts index b54e5a9a..b63aaf7d 100644 --- a/packages/core/src/sims/melee/nin/nin_lv100_sim.ts +++ b/packages/core/src/sims/melee/nin/nin_lv100_sim.ts @@ -1,14 +1,22 @@ -import { Ability, OgcdAbility, Buff, SimSettings, SimSpec } from "@xivgear/core/sims/sim_types"; -import { CycleProcessor, CycleSimResult, ExternalCycleSettings, MultiCycleSettings, AbilityUseResult, Rotation, PreDmgAbilityUseRecordUnf } from "@xivgear/core/sims/cycle_sim"; -import { CycleSettings } from "@xivgear/core/sims/cycle_settings"; -import { STANDARD_ANIMATION_LOCK } from "@xivgear/xivmath/xivconstants"; -import { potionMaxDex } from "@xivgear/core/sims/common/potion"; -import { Dokumori } from "@xivgear/core/sims/buffs"; +import {Ability, Buff, OgcdAbility, SimSettings, SimSpec} from "@xivgear/core/sims/sim_types"; +import { + AbilityUseResult, CutoffMode, + CycleProcessor, + CycleSimResult, + ExternalCycleSettings, + MultiCycleSettings, + PreDmgAbilityUseRecordUnf, + Rotation +} from "@xivgear/core/sims/cycle_sim"; +import {CycleSettings} from "@xivgear/core/sims/cycle_settings"; +import {STANDARD_ANIMATION_LOCK} from "@xivgear/xivmath/xivconstants"; +import {potionMaxDex} from "@xivgear/core/sims/common/potion"; +import {Dokumori} from "@xivgear/core/sims/buffs"; import NINGauge from "./nin_gauge"; -import { NinAbility, NinGcdAbility, MudraStep, NinjutsuAbility, NINExtraData } from "./nin_types"; +import {MudraStep, NinAbility, NINExtraData, NinGcdAbility, NinjutsuAbility} from "./nin_types"; import * as Actions from './nin_actions'; import * as Buffs from './nin_buffs'; -import { BaseMultiCycleSim } from "@xivgear/core/sims/processors/sim_processors"; +import {BaseMultiCycleSim} from "@xivgear/core/sims/processors/sim_processors"; export interface NinSimResult extends CycleSimResult { @@ -50,6 +58,7 @@ class RotationState { get combo() { return this._combo } + set combo(newCombo: number) { this._combo = newCombo; if (this._combo >= 3) this._combo = 0; @@ -140,7 +149,8 @@ class NINCycleProcessor extends CycleProcessor { // Use Raiju if it's available if (this.getBuffIfActive(Buffs.RaijuReady)) { fillerAction = Actions.Raiju; - } else { + } + else { // Use the next GCD in our basic combo fillerAction = Actions.SpinningEdge; switch (this.rotationState.combo) { @@ -153,7 +163,8 @@ class NINCycleProcessor extends CycleProcessor { const forceAeolian = this.getBuffIfActive(Buffs.KunaisBaneBuff) || this.getBuffIfActive(Dokumori); if (this.gauge.kazematoi <= 3 && (!forceAeolian || this.gauge.kazematoi === 0)) { fillerAction = Actions.ArmorCrush; - } else { + } + else { fillerAction = Actions.AeolianEdge; } break; @@ -274,7 +285,8 @@ class NINCycleProcessor extends CycleProcessor { this.useFillerGcd(); return idx; } - } else { + } + else { // Use the assigned ogcd based on a predefined order result = this.useOgcd(order[idx]); } @@ -283,7 +295,8 @@ class NINCycleProcessor extends CycleProcessor { // If our assigned ogcd was not used, use a filler ogcd if (result === null) { this.useFillerOgcd(1); - } else { + } + else { // Otherwise, continue with the ogcd order chain idx++; } @@ -302,7 +315,8 @@ class NINCycleProcessor extends CycleProcessor { */ if (phantomBuff && !comboIsBetter && (nextBuffWindow + 5 > phantomBuff.end || this.getBuffIfActive(Buffs.KunaisBaneBuff))) { this.usePhantom(); - } else { + } + else { this.useCombo(); } } @@ -416,12 +430,6 @@ export class NinSim extends BaseMultiCycleSim (this.totalTime - 5) && this.gauge.kenkiGauge >= 25; + const numShintens = Math.floor(this.gauge.kenkiGauge / 25); + const remainingGcds = rotation.slice(idx).filter(action => action.type === 'gcd').length; + return rotation[idx].type === 'gcd' && numShintens === remainingGcds; } override addAbilityUse(usedAbility: PreDmgAbilityUseRecordUnf) { @@ -88,17 +98,24 @@ export class SamSim extends BaseMultiCycleSim outer.use(cp, action)); + rotation.opener.forEach((action, idx) => { + if (action.type === 'gcd' && cp.shouldUseShinten(rotation.opener, idx)) { + outer.use(cp, HissatsuShinten); + } + outer.use(cp, action); + }); // Loop if (rotation.loop?.length) { cp.remainingCycles(() => { - rotation.loop.forEach(action => outer.use(cp, action)); + rotation.loop.forEach((action, idx) => { + if (action.type === 'gcd' && cp.shouldUseShinten(rotation.loop, idx)) { + outer.use(cp, HissatsuShinten); + } + outer.use(cp, action) + }); }); } } diff --git a/packages/core/src/sims/processors/sim_processors.ts b/packages/core/src/sims/processors/sim_processors.ts index 88a5c01d..6311cc8d 100644 --- a/packages/core/src/sims/processors/sim_processors.ts +++ b/packages/core/src/sims/processors/sim_processors.ts @@ -5,6 +5,7 @@ import { sum } from "@xivgear/core/util/array_utils"; import { addValues, applyStdDev, multiplyFixed } from "@xivgear/xivmath/deviation"; import { PartyBuff, SimSettings, SimSpec, Simulation } from "@xivgear/core/sims/sim_types"; import { + CutoffMode, CycleProcessor, CycleSimResult, CycleSimResultFull, @@ -86,6 +87,10 @@ export abstract class BaseMultiCycleSim used.totalDamageFull)); - const timeBasis = Math.min(cp.totalTime, cp.currentTime); + const timeBasis = cp.finalizedTimeBasis; const dps = multiplyFixed(totalDamage, 1.0 / timeBasis); const unbuffedPps = sum(used.map(used => used.totalPotency)) / cp.nextGcdTime; const buffTimings = [...cp.buffHistory]; diff --git a/packages/core/src/test/sims/combo_test.ts b/packages/core/src/test/sims/combo_test.ts index 6f62c2f5..7f81c59d 100644 --- a/packages/core/src/test/sims/combo_test.ts +++ b/packages/core/src/test/sims/combo_test.ts @@ -208,7 +208,8 @@ function quickTest(testCase: (cp: CycleProcessor) => void): FinalizedAbility[] { cycleTime: 120, stats: exampleGearSet.computedStats, totalTime: 295, - useAutos: false + useAutos: false, + cutoffMode: 'prorate-gcd' }); testCase(cp); const displayRecords = cp.finalizedRecords; diff --git a/packages/core/src/test/sims/cycle_processor_tests.ts b/packages/core/src/test/sims/cycle_processor_tests.ts index 68cd7ddb..d303371e 100644 --- a/packages/core/src/test/sims/cycle_processor_tests.ts +++ b/packages/core/src/test/sims/cycle_processor_tests.ts @@ -23,9 +23,16 @@ import { SimSettings, SimSpec } from "@xivgear/core/sims/sim_types"; -import {CycleProcessor, CycleSimResult, ExternalCycleSettings, Rotation} from "@xivgear/core/sims/cycle_sim"; +import { + CycleProcessor, + CycleSimResult, + ExternalCycleSettings, + MultiCycleSettings, + Rotation +} from "@xivgear/core/sims/cycle_sim"; import { BaseMultiCycleSim } from '@xivgear/core/sims/processors/sim_processors'; import {gemdraught1mind} from "../../sims/common/potion"; +import {expect} from "chai"; // Example of end-to-end simulation // This one is testing the simulation engine itself, so it copies the full simulation code rather than @@ -431,15 +438,20 @@ const long: GcdAbility = { cast: 8 }; +const defaultSettings: MultiCycleSettings = { + + allBuffs: [], + cycleTime: 30, + stats: exampleGearSet.computedStats, + totalTime: 120, + useAutos: false, + cutoffMode: 'prorate-gcd', + +}; + describe('Swiftcast', () => { it('should handle swiftcast correctly', () => { - const cp = new CycleProcessor({ - allBuffs: [], - cycleTime: 30, - stats: exampleGearSet.computedStats, - totalTime: 120, - useAutos: false - }); + const cp = new CycleProcessor(defaultSettings); cp.use(filler); cp.use(Swiftcast); cp.use(filler); @@ -479,13 +491,7 @@ describe('Swiftcast', () => { }); it('should not start combat', () => { - const cp = new CycleProcessor({ - allBuffs: [], - cycleTime: 30, - stats: exampleGearSet.computedStats, - totalTime: 120, - useAutos: false - }); + const cp = new CycleProcessor(defaultSettings); cp.use(Swiftcast); cp.use(filler); cp.use(filler); @@ -515,13 +521,7 @@ describe('Swiftcast', () => { }); it('should not be consumed by an instant skill', () => { - const cp = new CycleProcessor({ - allBuffs: [], - cycleTime: 30, - stats: exampleGearSet.computedStats, - totalTime: 120, - useAutos: false - }); + const cp = new CycleProcessor(defaultSettings); cp.use(filler); cp.use(Swiftcast); cp.use(instant); @@ -557,13 +557,7 @@ describe('Swiftcast', () => { }); it('should work correctly with a long cast', () => { - const cp = new CycleProcessor({ - allBuffs: [], - cycleTime: 30, - stats: exampleGearSet.computedStats, - totalTime: 120, - useAutos: false - }); + const cp = new CycleProcessor(defaultSettings); cp.use(filler); cp.use(Swiftcast); cp.use(long); @@ -632,13 +626,7 @@ const potBuffAbility: GcdAbility = { describe('Potency Buff Ability', () => { it('should increase the damage once', () => { - const cp = new CycleProcessor({ - allBuffs: [], - cycleTime: 30, - stats: exampleGearSet.computedStats, - totalTime: 120, - useAutos: false - }); + const cp = new CycleProcessor(defaultSettings); cp.use(filler); cp.use(potBuffAbility); cp.use(filler); @@ -734,13 +722,7 @@ const bristle2: GcdAbility = { describe('Damage Buff Ability', () => { // TODO: test a DoT it('should increase the damage once', () => { - const cp = new CycleProcessor({ - allBuffs: [], - cycleTime: 30, - stats: exampleGearSet.computedStats, - totalTime: 120, - useAutos: false - }); + const cp = new CycleProcessor(defaultSettings); cp.use(filler); cp.use(bristle); cp.use(filler); @@ -764,13 +746,7 @@ describe('Damage Buff Ability', () => { }); it('should increase the damage once, other style', () => { - const cp = new CycleProcessor({ - allBuffs: [], - cycleTime: 30, - stats: exampleGearSet.computedStats, - totalTime: 120, - useAutos: false - }); + const cp = new CycleProcessor(defaultSettings); cp.use(filler); cp.use(bristle2); cp.use(filler); @@ -793,13 +769,7 @@ describe('Damage Buff Ability', () => { assertClose(actualAbilities[3].directDamage, 15057.71, 1); }); it('should multiply direct damage and dots by default', () => { - const cp = new CycleProcessor({ - allBuffs: [], - cycleTime: 30, - stats: exampleGearSet.computedStats, - totalTime: 120, - useAutos: false - }); + const cp = new CycleProcessor(defaultSettings); cp.use(dia); cp.advanceTo(27); cp.use(bristle); @@ -833,13 +803,7 @@ describe('Damage Buff Ability', () => { assertClose(actualAbilities[3].totalDamage, dotTotal, 1); }); it('should filter abilities correctly', () => { - const cp = new CycleProcessor({ - allBuffs: [], - cycleTime: 30, - stats: exampleGearSet.computedStats, - totalTime: 120, - useAutos: false - }); + const cp = new CycleProcessor(defaultSettings); cp.use(filler); cp.use(bristle); // Bristle does not apply to this @@ -870,13 +834,7 @@ describe('Damage Buff Ability', () => { describe('Special record', () => { it('should be able to add and retrieve special records', () => { - const cp = new CycleProcessor({ - allBuffs: [], - cycleTime: 30, - stats: exampleGearSet.computedStats, - totalTime: 120, - useAutos: false - }); + const cp = new CycleProcessor(defaultSettings); cp.use(filler); cp.use(Swiftcast); cp.use(filler); @@ -950,13 +908,7 @@ describe('Cycle processor alignment options', () => { // In this test, the CycleProcessor should start the first cycle post-combat-start, thus the first cycle should // be shorter so that it can end on the 30-second mark, but the rest of the cycles should be perfectly aligned // on 30-second increments. - const cp = new CycleProcessor({ - allBuffs: [], - cycleTime: 30, - stats: exampleGearSet.computedStats, - totalTime: 120, - useAutos: false - }); + const cp = new CycleProcessor(defaultSettings); cp.use(fixed); cp.use(fixed); cp.remainingCycles(cp => { @@ -992,13 +944,7 @@ describe('Cycle processor alignment options', () => { // be longer so that the first cycle can end on the 30-second mark, but the rest of the cycles should be perfectly // aligned on 30-second increments. it('full alignment with in-cycle pre-pull', () => { - const cp = new CycleProcessor({ - allBuffs: [], - cycleTime: 30, - stats: exampleGearSet.computedStats, - totalTime: 120, - useAutos: false - }); + const cp = new CycleProcessor(defaultSettings); cp.remainingCycles(cp => { cp.useUntil(fixed, 'end'); }); @@ -1032,13 +978,7 @@ describe('Cycle processor alignment options', () => { // In this test, the CycleProcessor should start the first cycle post-combat-start, thus the first cycle should // be shorter so that it can end on the 30-second mark, but the rest of the cycles should be perfectly aligned // on 30-second increments. - const cp = new CycleProcessor({ - allBuffs: [], - cycleTime: 30, - stats: exampleGearSet.computedStats, - totalTime: 120, - useAutos: false - }); + const cp = new CycleProcessor(defaultSettings); cp.cycleLengthMode = 'align-to-first'; cp.use(fixed); cp.use(fixed); @@ -1075,13 +1015,7 @@ describe('Cycle processor alignment options', () => { // In this test, the CycleProcessor should start the first cycle post-combat-start, thus the first cycle should // be shorter so that it can end on the 30-second mark, but the rest of the cycles should be perfectly aligned // on 30-second increments. - const cp = new CycleProcessor({ - allBuffs: [], - cycleTime: 30, - stats: exampleGearSet.computedStats, - totalTime: 120, - useAutos: false - }); + const cp = new CycleProcessor(defaultSettings); cp.cycleLengthMode = 'align-to-first'; cp.oneCycle(cp => { // Longer GCD to make sure this actually takes up the full cycle time @@ -1122,13 +1056,7 @@ describe('Cycle processor alignment options', () => { assertClose(displayRecords[18].usedAt, 119); }); it('full duration with non-cycle pre-pull', () => { - const cp = new CycleProcessor({ - allBuffs: [], - cycleTime: 30, - stats: exampleGearSet.computedStats, - totalTime: 120, - useAutos: false - }); + const cp = new CycleProcessor(defaultSettings); cp.cycleLengthMode = 'full-duration'; cp.use(fixed); cp.use(fixed); @@ -1162,13 +1090,7 @@ describe('Cycle processor alignment options', () => { }); it('full duration with in-cycle pre-pull', () => { - const cp = new CycleProcessor({ - allBuffs: [], - cycleTime: 30, - stats: exampleGearSet.computedStats, - totalTime: 120, - useAutos: false - }); + const cp = new CycleProcessor(defaultSettings); cp.cycleLengthMode = 'full-duration'; cp.remainingCycles(cp => { cp.useUntil(fixed, 'end'); @@ -1218,11 +1140,8 @@ const fixedOdd: GcdAbility = { describe('Cycle processor re-alignment', () => { it('full alignment with non-cycle pre-pull', () => { const cp = new CycleProcessor({ - allBuffs: [], - cycleTime: 30, - stats: exampleGearSet.computedStats, - totalTime: 139, - useAutos: false + ...defaultSettings, + totalTime: 139 }); cp.use(fixedOdd); cp.use(fixedOdd); @@ -1253,11 +1172,8 @@ describe('Cycle processor re-alignment', () => { }); it('full alignment with in-cycle pre-pull', () => { const cp = new CycleProcessor({ - allBuffs: [], - cycleTime: 30, - stats: exampleGearSet.computedStats, + ...defaultSettings, totalTime: 170, - useAutos: false }); cp.remainingCycles(cp => { cp.useUntil(fixedOdd, 'end'); @@ -1289,11 +1205,8 @@ describe('Cycle processor re-alignment', () => { }); it('first-cycle alignment with out-of-cycle pre-pull', () => { const cp = new CycleProcessor({ - allBuffs: [], - cycleTime: 30, - stats: exampleGearSet.computedStats, + ...defaultSettings, totalTime: 145, - useAutos: false }); cp.cycleLengthMode = 'align-to-first'; cp.use(fixedOdd); @@ -1330,11 +1243,8 @@ describe('Cycle processor re-alignment', () => { // be shorter so that it can end on the 30-second mark, but the rest of the cycles should be perfectly aligned // on 30-second increments. const cp = new CycleProcessor({ - allBuffs: [], - cycleTime: 30, - stats: exampleGearSet.computedStats, + ...defaultSettings, totalTime: 160, - useAutos: false }); cp.cycleLengthMode = 'align-to-first'; cp.remainingCycles(cp => { @@ -1368,11 +1278,8 @@ describe('Cycle processor re-alignment', () => { }); it('full duration with non-cycle pre-pull', () => { const cp = new CycleProcessor({ - allBuffs: [], - cycleTime: 30, - stats: exampleGearSet.computedStats, + ...defaultSettings, totalTime: 300, - useAutos: false }); cp.cycleLengthMode = 'full-duration'; cp.use(fixedOdd); @@ -1392,11 +1299,8 @@ describe('Cycle processor re-alignment', () => { }); it('full duration with in-cycle pre-pull', () => { const cp = new CycleProcessor({ - allBuffs: [], - cycleTime: 30, - stats: exampleGearSet.computedStats, + ...defaultSettings, totalTime: 295, - useAutos: false }); cp.cycleLengthMode = 'full-duration'; cp.remainingCycles(cp => { @@ -1441,11 +1345,8 @@ const indefAb: Ability = { describe('indefinite buff handling', () => { it('can handle a manually applied indefinite buff', () => { const cp = new CycleProcessor({ - allBuffs: [], - cycleTime: 30, - stats: exampleGearSet.computedStats, + ...defaultSettings, totalTime: 295, - useAutos: false }); cp.activateBuff(indefBuff); cp.remainingCycles(cp => { @@ -1461,11 +1362,8 @@ describe('indefinite buff handling', () => { }); it('can handle an automatically applied indefinite buff', () => { const cp = new CycleProcessor({ - allBuffs: [], - cycleTime: 30, - stats: exampleGearSet.computedStats, + ...defaultSettings, totalTime: 295, - useAutos: false }); cp.oneCycle(cp => { cp.use(filler); @@ -1503,11 +1401,9 @@ const longDelay: GcdAbility = { describe('application delay', () => { it('should default if not specified', () => { const cp = new CycleProcessor({ - allBuffs: [], + ...defaultSettings, cycleTime: 120, - stats: exampleGearSet.computedStats, totalTime: 120, - useAutos: false }); cp.use(filler); cp.use(filler); @@ -1526,11 +1422,9 @@ describe('application delay', () => { }); it('should respect an override', () => { const cp = new CycleProcessor({ - allBuffs: [], + ...defaultSettings, cycleTime: 120, - stats: exampleGearSet.computedStats, totalTime: 120, - useAutos: false }); cp.use(longDelay); cp.use(longDelay); @@ -1553,11 +1447,9 @@ describe('application delay', () => { describe('gcd clipping check', () => { it('can check if an ogcd ability can be used without clipping', () => { const cp = new CycleProcessor({ - allBuffs: [], + ...defaultSettings, cycleTime: 120, - stats: exampleGearSet.computedStats, totalTime: 120, - useAutos: false }); cp.use(filler); let canUse = cp.canUseWithoutClipping(assize); @@ -1572,11 +1464,9 @@ describe('gcd clipping check', () => { describe('potion logic', () => { it('reflects potions', () => { const cp = new CycleProcessor({ - allBuffs: [], + ...defaultSettings, cycleTime: 120, - stats: exampleGearSet.computedStats, totalTime: 120, - useAutos: false }); cp.use(filler); cp.use(gemdraught1mind); @@ -1596,4 +1486,271 @@ describe('potion logic', () => { assertClose(actualAbilities[3].directDamage, 15057.71, 0.01); }); +}); + +describe('cutoff modes', () => { + it('supports default prorate-gcd mode', () => { + // For this mode, the fight duration should be treated as 30 seconds, but the final GCD will be prorated + // based on how much of the full GCD time (regardless of the cast time) would have fit. + const cp = new CycleProcessor({ + ...defaultSettings, + cycleTime: 30, + totalTime: 30, + cutoffMode: 'prorate-gcd' + }); + cp.useUntil(filler, 50); + const displayRecords = cp.finalizedRecords; + const actualAbilities: FinalizedAbility[] = displayRecords.filter((record): record is FinalizedAbility => { + return 'ability' in record; + }); + // Should take up full time + expect(cp.finalizedTimeBasis).to.equal(30); + expect(actualAbilities).to.have.length(14); + const firstAction = actualAbilities[0]; + expect(firstAction.partialRate).to.be.null; + expect(firstAction.directDamage).to.be.closeTo(15057.71, 0.1); + // Final action should get prorated + const finalAction = actualAbilities[13]; + expect(finalAction.usedAt).to.be.closeTo(28.55, 0.01); + expect(finalAction.original.totalTimeTaken).to.be.closeTo(2.31, 0.01); + // 0.6277 ~= (30 - 28.55) / 2.31 + expect(finalAction.partialRate).to.be.closeTo(0.6277, 0.01); + expect(finalAction.directDamage).to.be.closeTo(9451.81, 0.1); + }); + it('supports prorate-application mode', () => { + // For this mode, the fight duration should be treated as 30 seconds, but the final GCD will be prorated + // based on how much of the time between GCD start and application time would have fit into the remaining + // fight duration. + const cp = new CycleProcessor({ + ...defaultSettings, + cycleTime: 30, + totalTime: 30, + cutoffMode: 'prorate-application' + }); + cp.useUntil(filler, 50); + const displayRecords = cp.finalizedRecords; + const actualAbilities: FinalizedAbility[] = displayRecords.filter((record): record is FinalizedAbility => { + return 'ability' in record; + }); + // Should take up full time + expect(cp.finalizedTimeBasis).to.equal(30); + expect(actualAbilities).to.have.length(14); + const firstAction = actualAbilities[0]; + expect(firstAction.partialRate).to.be.null; + expect(firstAction.directDamage).to.be.closeTo(15057.71, 0.1); + // Final action should get prorated + const finalAction = actualAbilities[13]; + expect(finalAction.usedAt).to.be.closeTo(28.55, 0.01); + expect(finalAction.original.appDelayFromStart).to.be.closeTo(1.48, 0.01); + // 0.9797 ~= (30 - 28.55) / 1.48 + expect(finalAction.partialRate).to.be.closeTo(0.9797, 0.01); + expect(finalAction.directDamage).to.be.closeTo(14752.4865, 0.1); + }); + it('supports lax-gcd mode', () => { + // For this mode, the fight duration will be extended to fit the final GCD. + const cp = new CycleProcessor({ + ...defaultSettings, + cycleTime: 30, + totalTime: 30, + cutoffMode: 'lax-gcd' + }); + cp.useUntil(filler, 50); + const displayRecords = cp.finalizedRecords; + const actualAbilities: FinalizedAbility[] = displayRecords.filter((record): record is FinalizedAbility => { + return 'ability' in record; + }); + // Fight length is extended to hold the final GCD + expect(cp.finalizedTimeBasis).to.be.closeTo(30.8599, 0.01); + expect(actualAbilities).to.have.length(14); + const firstAction = actualAbilities[0]; + expect(firstAction.partialRate).to.be.null; + expect(firstAction.directDamage).to.be.closeTo(15057.71, 0.1); + // Not prorated + const finalAction = actualAbilities[13]; + expect(finalAction.usedAt).to.be.closeTo(28.55, 0.01); + // Gets the full damage + expect(finalAction.partialRate).to.be.null; + expect(finalAction.directDamage).to.be.closeTo(15057.71, 0.1); + }); + it('supports lax-gcd mode, with many oGCDs padding out the fight length', () => { + // For this mode, the fight duration will be extended to fit the final GCD. + const cp = new CycleProcessor({ + ...defaultSettings, + cycleTime: 30, + totalTime: 30, + cutoffMode: 'lax-gcd' + }); + cp.useUntil(filler, 50); + cp.use(assize); + cp.use(assize); + cp.use(assize); + cp.use(assize); + const displayRecords = cp.finalizedRecords; + const actualAbilities: FinalizedAbility[] = displayRecords.filter((record): record is FinalizedAbility => { + return 'ability' in record; + }); + // Fight length is extended to hold the final GCD and the clipping oGCDs + expect(cp.finalizedTimeBasis).to.be.closeTo(31.23, 0.01); + expect(actualAbilities).to.have.length(16); + const firstAction = actualAbilities[0]; + expect(firstAction.partialRate).to.be.null; + expect(firstAction.directDamage).to.be.closeTo(15057.71, 0.1); + // Final GCD should be full + const finalGcd = actualAbilities[13]; + expect(finalGcd.usedAt).to.be.closeTo(28.55, 0.01); + // Gets the full damage + expect(finalGcd.partialRate).to.be.null; + expect(finalGcd.directDamage).to.be.closeTo(15057.71, 0.1); + // Final oGCD should be full + const finalOGcd = actualAbilities[15]; + // We're still allowed to squeeze in more oGCDs because it's still weaved into a valid GCD + expect(finalOGcd.usedAt).to.be.closeTo(30.63, 0.01); + // Gets the full damage + expect(finalOGcd.partialRate).to.be.null; + expect(finalOGcd.directDamage).to.be.closeTo(19452.27, 0.1); + }); + it('supports lax-gcd mode, with long casts', () => { + // For this mode, the fight duration will be extended to fit the final GCD. + const cp = new CycleProcessor({ + ...defaultSettings, + cycleTime: 30, + totalTime: 30, + cutoffMode: 'lax-gcd' + }); + cp.use(filler); + cp.use(filler); + cp.advanceTo(20); + // This should fit entirely + // Due to SpS, it gets a ~7.5s cast time + cp.use(long); + // This should also fit, but goes over time + cp.use(long); + // This should not fit, as it goes beyond where the previous GCD should have ended + cp.use(assize); + const displayRecords = cp.finalizedRecords; + const actualAbilities: FinalizedAbility[] = displayRecords.filter((record): record is FinalizedAbility => { + return 'ability' in record; + }); + // Fight length is increased so that there it fits what would otherwise be a partial GCD at the end + expect(cp.finalizedTimeBasis).to.be.closeTo(34.98, 0.01); + expect(actualAbilities).to.have.length(4); + const firstAction = actualAbilities[0]; + expect(firstAction.partialRate).to.be.null; + expect(firstAction.directDamage).to.be.closeTo(15057.71, 0.1); + // full damage + const finalAction = actualAbilities[3]; + expect(finalAction.usedAt).to.be.closeTo(27.49, 0.01); + // Gets the full damage + expect(finalAction.partialRate).to.be.null; + expect(finalAction.directDamage).to.be.closeTo(15057.71, 0.1); + }); + it('supports strict-gcd mode', () => { + // In this mode, GCDs will be dropped entirely if they would not fit into the fight duration. + // The fight duration is the time where that GCD would have started. + const cp = new CycleProcessor({ + ...defaultSettings, + cycleTime: 30, + totalTime: 30, + cutoffMode: 'strict-gcd' + }); + cp.useUntil(filler, 50); + const displayRecords = cp.finalizedRecords; + const actualAbilities: FinalizedAbility[] = displayRecords.filter((record): record is FinalizedAbility => { + return 'ability' in record; + }); + // Fight length is reduced so that there is no partial GCD at the end + expect(cp.finalizedTimeBasis).to.be.closeTo(28.55, 0.01); + expect(actualAbilities).to.have.length(13); + const firstAction = actualAbilities[0]; + expect(firstAction.partialRate).to.be.null; + expect(firstAction.directDamage).to.be.closeTo(15057.71, 0.1); + // full damage + const finalAction = actualAbilities[12]; + expect(finalAction.usedAt).to.be.closeTo(26.24, 0.01); + // Gets the full damage + expect(finalAction.partialRate).to.be.null; + expect(finalAction.directDamage).to.be.closeTo(15057.71, 0.1); + }); + it('supports strict-gcd mode, with oGCDs padding fight length', () => { + // In this mode, GCDs will be dropped entirely if they would not fit into the fight duration. + // The fight duration is the time where that GCD would have started. + // This test checks behavior for when we drop more oGCDs than expected at the end (i.e. we would + // be hard clipping the next GCD). + // The current implementation allows a single over-time oGCD to count. + const cp = new CycleProcessor({ + ...defaultSettings, + cycleTime: 30, + totalTime: 30, + cutoffMode: 'strict-gcd' + }); + cp.use(filler); + cp.use(filler); + cp.advanceTo(26); + // This should fit entirely + cp.use(filler); + // But not all of these should fit + cp.use(assize); + cp.use(assize); + cp.use(assize); + cp.use(assize); + cp.use(assize); + cp.use(assize); + // These should all be ignored completely + cp.use(filler); + cp.use(assize); + cp.use(assize); + cp.use(assize); + const displayRecords = cp.finalizedRecords; + const actualAbilities: FinalizedAbility[] = displayRecords.filter((record): record is FinalizedAbility => { + return 'ability' in record; + }); + // Fight length is reduced so that there is no partial GCD at the end + expect(cp.finalizedTimeBasis).to.be.closeTo(30.480, 0.01); + expect(actualAbilities).to.have.length(8); + const firstAction = actualAbilities[0]; + expect(firstAction.partialRate).to.be.null; + expect(firstAction.directDamage).to.be.closeTo(15057.71, 0.1); + // full damage + const finalAction = actualAbilities[7]; + expect(finalAction.usedAt).to.be.closeTo(29.88, 0.01); + // Gets the full damage + expect(finalAction.partialRate).to.be.null; + expect(finalAction.directDamage).to.be.closeTo(19452.27, 0.1); + }); + it('supports strict-gcd mode, and does not allow long-cast GCDs to violate the time limit', () => { + // In this mode, GCDs will be dropped entirely if they would not fit into the fight duration. + // The fight duration is the time where that GCD would have started. + // This test checks that cast time, in addition to recast (GCD) time, is factored in to this decision. + const cp = new CycleProcessor({ + ...defaultSettings, + cycleTime: 30, + totalTime: 30, + cutoffMode: 'strict-gcd' + }); + cp.use(filler); + cp.use(filler); + cp.advanceTo(20); + // This should fit entirely + // Due to SpS, it gets a ~7.5s cast time + cp.use(long); + // This should not fit at all + cp.use(long); + cp.use(assize); + const displayRecords = cp.finalizedRecords; + const actualAbilities: FinalizedAbility[] = displayRecords.filter((record): record is FinalizedAbility => { + return 'ability' in record; + }); + // Fight length is reduced so that there is no partial GCD at the end + expect(cp.finalizedTimeBasis).to.be.closeTo(27.49, 0.01); + expect(actualAbilities).to.have.length(3); + const firstAction = actualAbilities[0]; + expect(firstAction.partialRate).to.be.null; + expect(firstAction.directDamage).to.be.closeTo(15057.71, 0.1); + // full damage + const finalAction = actualAbilities[2]; + expect(finalAction.usedAt).to.be.closeTo(20, 0.01); + // Gets the full damage + expect(finalAction.partialRate).to.be.null; + expect(finalAction.directDamage).to.be.closeTo(15057.71, 0.1); + }); }); \ No newline at end of file