Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Shared cooldown support #137

Merged
merged 3 commits into from
Jun 18, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
129 changes: 92 additions & 37 deletions packages/core/src/sims/common/cooldown_manager.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import {Ability} from "../sim_types";
import {Ability, CdAbility} from "../sim_types";

export type CooldownMode = 'none' | 'warn' | 'delay' | 'reject';

Expand Down Expand Up @@ -47,7 +47,7 @@ class InternalState {
export class CooldownTracker {


private readonly currentState: Map<string, InternalState> = new Map();
private readonly currentState: Map<number, InternalState> = new Map();
public mode: CooldownMode = 'warn';

public constructor(private timeSource: () => number, mode: CooldownMode = 'warn') {
Expand All @@ -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`);
Expand All @@ -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 {
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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,
}
}
}
}
Expand All @@ -222,4 +248,33 @@ function defaultStatus(ability: Ability, absTime: number): CooldownStatus {
},
currentCharges: ability.cooldown?.charges ?? 1,
}
}
}

/**
* 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;
}
31 changes: 29 additions & 2 deletions packages/core/src/sims/sim_types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
*/
Expand All @@ -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
*/
Expand Down
Loading
Loading