diff --git a/packages/core/src/sims/common/cooldown_manager.ts b/packages/core/src/sims/common/cooldown_manager.ts index a5860ae9..b57e3060 100644 --- a/packages/core/src/sims/common/cooldown_manager.ts +++ b/packages/core/src/sims/common/cooldown_manager.ts @@ -1,4 +1,4 @@ -import {Ability} from "../sim_types"; +import {Ability, CdAbility} from "../sim_types"; export type CooldownMode = 'none' | 'warn' | 'delay' | 'reject'; @@ -47,7 +47,7 @@ class InternalState { export class CooldownTracker { - private readonly currentState: Map = new Map(); + private readonly currentState: Map = new Map(); public mode: CooldownMode = 'warn'; public constructor(private timeSource: () => number, mode: CooldownMode = 'warn') { @@ -59,11 +59,10 @@ export class CooldownTracker { } public useAbility(ability: Ability, cdTimeOverride?: number): void { - const cd = ability.cooldown; - // No CD info, don't do anything - if (!cd) { + if (!hasCooldown(ability)) { return; } + const cd = ability.cooldown; if (cd.reducedBy !== undefined && cd.reducedBy !== 'none' && cdTimeOverride === undefined) { // TODO: not super happy about the logic being split up here, find a better way console.warn(`CD ${ability.name} is supposed to be reduced by ${cd.reducedBy}, but an override time was not passed in`); @@ -89,7 +88,8 @@ export class CooldownTracker { // seconds. The trivial case is the ability is capped 'now' and you use it (works for charge based and normal). const newCappedAt = status.cappedAt.absolute + cdTime; const state = new InternalState(newCappedAt); - this.currentState.set(ability.name, state); + const key = cooldownKey(ability); + this.currentState.set(key, state); } public canUse(ability: Ability, when?: number): boolean { @@ -118,10 +118,10 @@ export class CooldownTracker { } public statusOfAt(ability: Ability, desiredTime: number): CooldownStatus { - if (!ability.cooldown) { + if (!hasCooldown(ability)) { return defaultStatus(ability, desiredTime); } - const existing = this.currentState.get(ability.name); + const existing = this.currentState.get(cooldownKey(ability)); // Have existing state if (existing) { const cappedAt = existing.cappedAt; @@ -159,45 +159,71 @@ export class CooldownTracker { currentCharges = 1, i = 0, timeUntilCap = 60 currentCharges = 0, i = 60, timeUntilCap = 60 */ - let currentCharges = ability.cooldown.charges ?? 1; - let remainingTime; - let timeUntilNextCharge; - for (remainingTime = 0; remainingTime < timeUntilCap; remainingTime += ability.cooldown.time) { - currentCharges--; - timeUntilNextCharge = timeUntilCap - remainingTime; - } - // Not capped, but have a charge - if (currentCharges >= 1) { + const maxCharges = ability.cooldown.charges ?? 1; + // There is an unsupported case here, where mixed length CDs/charge counts are not supported + // with shared CDs. + // This branch is meant to handle this case when using one-charge abilities. + // TODO: consider whether there is a way to block this case using type defs. + if (maxCharges === 1) { return { readyAt: { - absolute: desiredTime, - relative: 0 + absolute: cappedAt, + relative: cappedAt - desiredTime }, - readyToUse: true, + readyToUse: false, capped: false, cappedAt: { absolute: cappedAt, relative: cappedAt - desiredTime, }, - currentCharges: currentCharges, + currentCharges: 0, } } else { - // e.g. if CD is 60 seconds, two charges, and we have 75 seconds until capped, - // then we will have a charge available in (75 mod 60) === 15 seconds - const remaining = timeUntilNextCharge; - return { - readyAt: { - absolute: remaining + desiredTime, - relative: remaining - }, - readyToUse: false, - capped: false, - cappedAt: { - absolute: cappedAt, - relative: cappedAt - desiredTime, - }, - currentCharges: currentCharges, + + let currentCharges = maxCharges; + let remainingTime; + let timeUntilNextCharge; + for (remainingTime = 0; remainingTime < timeUntilCap; remainingTime += ability.cooldown.time) { + currentCharges--; + timeUntilNextCharge = timeUntilCap - remainingTime; + } + if (currentCharges < 0) { + currentCharges = 0; + } + // Not capped, but have a charge + if (currentCharges >= 1) { + return { + readyAt: { + absolute: desiredTime, + relative: 0 + }, + readyToUse: true, + capped: false, + cappedAt: { + absolute: cappedAt, + relative: cappedAt - desiredTime, + }, + currentCharges: currentCharges, + } + } + else { + // e.g. if CD is 60 seconds, two charges, and we have 75 seconds until capped, + // then we will have a charge available in (75 mod 60) === 15 seconds + const remaining = timeUntilNextCharge; + return { + readyAt: { + absolute: remaining + desiredTime, + relative: remaining + }, + readyToUse: false, + capped: false, + cappedAt: { + absolute: cappedAt, + relative: cappedAt - desiredTime, + }, + currentCharges: currentCharges, + } } } } @@ -222,4 +248,33 @@ function defaultStatus(ability: Ability, absTime: number): CooldownStatus { }, currentCharges: ability.cooldown?.charges ?? 1, } -} \ No newline at end of file +} + +/** + * Given that abilities can indicate that they share a cooldown with another ability, this function extracts + * the "real" cooldown key. + * @param ability + */ +function cooldownKey(ability: CdAbility): number { + const seen = []; + let current = ability; + let attempts = 10; + // This SHOULDN'T happen with the new types, but leaving this check just in case. + while (--attempts > 0) { + if (current.cooldown.sharesCooldownWith !== undefined) { + current = current.cooldown.sharesCooldownWith; + if (seen.includes(current)) { + throw Error(`Ability ${ability.name} has circular references of CD sharing.`) + } + seen.push(current); + } + else { + return current.id; + } + } + throw Error(`Ability ${ability.name} has too many layers of nested CD share.`) +} + +function hasCooldown(ability: Ability): ability is CdAbility { + return ability.cooldown !== undefined; +} diff --git a/packages/core/src/sims/sim_types.ts b/packages/core/src/sims/sim_types.ts index 5f53074d..f60e888e 100644 --- a/packages/core/src/sims/sim_types.ts +++ b/packages/core/src/sims/sim_types.ts @@ -291,7 +291,7 @@ export type BaseAbility = Readonly<{ /** * Represents the cooldown of an ability */ -export type Cooldown = Readonly<{ +export type BaseCooldown = Readonly<{ /** * The cooldown duration, or the time to regain a single charge */ @@ -301,11 +301,38 @@ export type Cooldown = Readonly<{ */ reducedBy?: 'none' | 'spellspeed' | 'skillspeed'; /** - * The number of charges of the ability + * The number of charges of the ability. */ charges?: number }> +export type OriginCooldown = BaseCooldown & { + sharesCooldownWith?: never; +} + +export type SharedCooldown = BaseCooldown & { + /** + * If the ability shares a cooldown with another ability, specify that ability here. + * + * When using shared cooldowns, the reference should only be made in one direction. That is, + * if A, B, and C share cooldowns, then B and C should set their shared cooldown to A, and A should not have a + * shared cooldown. + */ + sharesCooldownWith: Ability & { + cooldown: OriginCooldown + } +} + +export type Cooldown = OriginCooldown | SharedCooldown; +export type OriginCdAbility = Ability & { + cooldown: OriginCooldown; +} +export type SharedCdAbility = Ability & { + cooldown: SharedCooldown; +} + +export type CdAbility = OriginCdAbility | SharedCdAbility; + /** * Represents a GCD action */ diff --git a/packages/frontend/src/scripts/test/sims/cooldown_tests.ts b/packages/frontend/src/scripts/test/sims/cooldown_tests.ts index 9cfa665a..9ce88179 100644 --- a/packages/frontend/src/scripts/test/sims/cooldown_tests.ts +++ b/packages/frontend/src/scripts/test/sims/cooldown_tests.ts @@ -1,9 +1,9 @@ import {CooldownTracker} from "@xivgear/core/sims/common/cooldown_manager"; import {Chain} from "@xivgear/core/sims/buffs"; import * as assert from "assert"; -import {GcdAbility, OgcdAbility} from "@xivgear/core/sims/sim_types"; +import {GcdAbility, OgcdAbility, OriginCdAbility, SharedCdAbility} from "@xivgear/core/sims/sim_types"; -const chain: OgcdAbility = { +const chain: OgcdAbility & OriginCdAbility = { type: 'ogcd', name: "Chain", id: 7436, @@ -15,6 +15,19 @@ const chain: OgcdAbility = { } }; +const chainShared: OgcdAbility & SharedCdAbility = { + type: 'ogcd', + name: 'Chain II', + id: 100_0001, + activatesBuffs: [Chain], + potency: null, + attackType: "Ability", + cooldown: { + time: 60, + sharesCooldownWith: chain + } +}; + const phlegma: GcdAbility = { type: 'gcd', name: "Phlegma", @@ -567,4 +580,353 @@ describe('cooldown manager', () => { currentCharges: 3, }); }); + it('handles shared CDs', () => { + const ts = new FakeTimeSource(); + const tracker = new CooldownTracker(() => ts.time, 'reject'); + const ability = chain; + const ability2 = chainShared; + // Validate initial state + const e1 = { + readyToUse: true, + readyAt: { + absolute: 0, + relative: 0 + }, + capped: true, + cappedAt: { + absolute: 0, + relative: 0 + }, + currentCharges: 1, + }; + assert.deepEqual(tracker.statusOf(ability), e1); + assert.deepEqual(tracker.statusOf(ability2), e1); + // Use first ability + tracker.useAbility(ability); + const e2 = { + readyToUse: false, + readyAt: { + absolute: 120, + relative: 120 + }, + capped: false, + cappedAt: { + absolute: 120, + relative: 120 + }, + currentCharges: 0, + }; + assert.deepEqual(tracker.statusOf(ability), e2); + assert.deepEqual(tracker.statusOf(ability2), e2); + ts.time = 30; + const e3 = { + readyToUse: false, + readyAt: { + absolute: 120, + relative: 90 + }, + capped: false, + cappedAt: { + absolute: 120, + relative: 90 + }, + currentCharges: 0, + }; + assert.deepEqual(tracker.statusOf(ability), e3); + assert.deepEqual(tracker.statusOf(ability2), e3); + + // Move to 90s + ts.time = 90; + const e4 = { + readyToUse: false, + readyAt: { + absolute: 120, + relative: 30 + }, + capped: false, + cappedAt: { + absolute: 120, + relative: 30 + }, + currentCharges: 0, + }; + assert.deepEqual(tracker.statusOf(ability), e4); + assert.deepEqual(tracker.statusOf(ability2), e4); + + ts.time = 120; + const e5 = { + readyToUse: true, + readyAt: { + absolute: 120, + relative: 0 + }, + capped: true, + cappedAt: { + absolute: 120, + relative: 0 + }, + currentCharges: 1, + }; + assert.deepEqual(tracker.statusOf(ability), e5); + assert.deepEqual(tracker.statusOf(ability2), e5); + + ts.time = 150; + const e6 = { + readyToUse: true, + readyAt: { + absolute: 150, + relative: 0 + }, + capped: true, + cappedAt: { + absolute: 150, + relative: 0 + }, + currentCharges: 1, + }; + assert.deepEqual(tracker.statusOf(ability), e6); + assert.deepEqual(tracker.statusOf(ability2), e6); + + tracker.useAbility(ability2); + const e7 = { + readyToUse: false, + readyAt: { + absolute: 210, + relative: 60 + }, + capped: false, + cappedAt: { + absolute: 210, + relative: 60 + }, + currentCharges: 0, + }; + assert.deepEqual(tracker.statusOf(ability), e7); + assert.deepEqual(tracker.statusOf(ability2), e7); + + ts.time = 180; + const e8 = { + readyToUse: false, + readyAt: { + absolute: 210, + relative: 30 + }, + capped: false, + cappedAt: { + absolute: 210, + relative: 30 + }, + currentCharges: 0, + }; + assert.deepEqual(tracker.statusOf(ability), e8); + assert.deepEqual(tracker.statusOf(ability2), e8); + + ts.time = 210; + const e9 = { + readyToUse: true, + readyAt: { + absolute: 210, + relative: 0 + }, + capped: true, + cappedAt: { + absolute: 210, + relative: 0 + }, + currentCharges: 1, + }; + assert.deepEqual(tracker.statusOf(ability), e9); + assert.deepEqual(tracker.statusOf(ability2), e9); + + ts.time = 240; + const e10 = { + readyToUse: true, + readyAt: { + absolute: 240, + relative: 0 + }, + capped: true, + cappedAt: { + absolute: 240, + relative: 0 + }, + currentCharges: 1, + }; + assert.deepEqual(tracker.statusOf(ability), e10); + assert.deepEqual(tracker.statusOf(ability2), e10); + }); + it('handles shared CDs when the shared CD is used first', () => { + const ts = new FakeTimeSource(); + const tracker = new CooldownTracker(() => ts.time, 'reject'); + const ability = chain; + const ability2 = chainShared; + // Validate initial state + const e1 = { + readyToUse: true, + readyAt: { + absolute: 0, + relative: 0 + }, + capped: true, + cappedAt: { + absolute: 0, + relative: 0 + }, + currentCharges: 1, + }; + assert.deepEqual(tracker.statusOf(ability), e1); + assert.deepEqual(tracker.statusOf(ability2), e1); + // Use first ability + tracker.useAbility(ability2); + const e2 = { + readyToUse: false, + readyAt: { + absolute: 60, + relative: 60 + }, + capped: false, + cappedAt: { + absolute: 60, + relative: 60 + }, + currentCharges: 0, + }; + assert.deepEqual(tracker.statusOf(ability), e2); + assert.deepEqual(tracker.statusOf(ability2), e2); + ts.time = 20; + const e3 = { + readyToUse: false, + readyAt: { + absolute: 60, + relative: 40 + }, + capped: false, + cappedAt: { + absolute: 60, + relative: 40 + }, + currentCharges: 0, + }; + assert.deepEqual(tracker.statusOf(ability), e3); + assert.deepEqual(tracker.statusOf(ability2), e3); + + ts.time = 60; + const e4 = { + readyToUse: true, + readyAt: { + absolute: 60, + relative: 0 + }, + capped: true, + cappedAt: { + absolute: 60, + relative: 0 + }, + currentCharges: 1, + }; + assert.deepEqual(tracker.statusOf(ability), e4); + assert.deepEqual(tracker.statusOf(ability2), e4); + + ts.time = 120; + const e5 = { + readyToUse: true, + readyAt: { + absolute: 120, + relative: 0 + }, + capped: true, + cappedAt: { + absolute: 120, + relative: 0 + }, + currentCharges: 1, + }; + assert.deepEqual(tracker.statusOf(ability), e5); + assert.deepEqual(tracker.statusOf(ability2), e5); + + ts.time = 90; + const e6 = { + readyToUse: true, + readyAt: { + absolute: 90, + relative: 0 + }, + capped: true, + cappedAt: { + absolute: 90, + relative: 0 + }, + currentCharges: 1, + }; + assert.deepEqual(tracker.statusOf(ability), e6); + assert.deepEqual(tracker.statusOf(ability2), e6); + + tracker.useAbility(ability); + const e7 = { + readyToUse: false, + readyAt: { + absolute: 210, + relative: 120 + }, + capped: false, + cappedAt: { + absolute: 210, + relative: 120 + }, + currentCharges: 0, + }; + assert.deepEqual(tracker.statusOf(ability), e7); + assert.deepEqual(tracker.statusOf(ability2), e7); + + ts.time = 180; + const e8 = { + readyToUse: false, + readyAt: { + absolute: 210, + relative: 30 + }, + capped: false, + cappedAt: { + absolute: 210, + relative: 30 + }, + currentCharges: 0, + }; + assert.deepEqual(tracker.statusOf(ability), e8); + assert.deepEqual(tracker.statusOf(ability2), e8); + + ts.time = 210; + const e9 = { + readyToUse: true, + readyAt: { + absolute: 210, + relative: 0 + }, + capped: true, + cappedAt: { + absolute: 210, + relative: 0 + }, + currentCharges: 1, + }; + assert.deepEqual(tracker.statusOf(ability), e9); + assert.deepEqual(tracker.statusOf(ability2), e9); + + ts.time = 240; + const e10 = { + readyToUse: true, + readyAt: { + absolute: 240, + relative: 0 + }, + capped: true, + cappedAt: { + absolute: 240, + relative: 0 + }, + currentCharges: 1, + }; + assert.deepEqual(tracker.statusOf(ability), e10); + assert.deepEqual(tracker.statusOf(ability2), e10); + }); });