Skip to content

Commit

Permalink
add thrall damage support
Browse files Browse the repository at this point in the history
closes #204
closes #213

Co-authored-by: Mark Koester <[email protected]>
  • Loading branch information
LlemonDuck and MarkKoester committed Feb 5, 2024
1 parent ac62742 commit ebeac41
Show file tree
Hide file tree
Showing 7 changed files with 78 additions and 41 deletions.
20 changes: 20 additions & 0 deletions src/app/components/player/ExtraOptions.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import diary from '@/public/img/misc/diary.png';
import forinthry_surge from '@/public/img/misc/forinthry_surge.webp';
import soulreaper_axe from '@/public/img/misc/soulreaper_axe.png';
import NumberInput from '@/app/components/generic/NumberInput';
import arceuus from '@/public/img/misc/arceuus.png';
import Toggle from '../generic/Toggle';

const ExtraOptions: React.FC = observer(() => {
Expand Down Expand Up @@ -92,6 +93,25 @@ const ExtraOptions: React.FC = observer(() => {
</>
)}
/>
<Toggle
checked={player.buffs.thrallSpell}
setChecked={(c) => store.updatePlayer({ buffs: { thrallSpell: c } })}
label={(
<>
<img src={arceuus.src} width={18} className="inline-block" alt="Thralls" />
{' '}
Using thralls
{' '}
<span
className="align-super underline decoration-dotted cursor-help text-xs text-gray-300"
data-tooltip-id="tooltip"
data-tooltip-content="Include thrall damage in calculations."
>
?
</span>
</>
)}
/>
<div className="w-full">
<NumberInput
className="form-control w-12"
Expand Down
12 changes: 12 additions & 0 deletions src/lib/HitDist.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { cross, max, sum } from 'd3-array';
import { HistogramEntry } from '@/types/State';
import { THRALL_MAX } from '@/lib/constants';

export type HitTransformer = (hitsplat: number) => HitDistribution;

Expand Down Expand Up @@ -69,12 +70,23 @@ export class HitDistribution {
[new WeightedHit(1.0, [0])],
);

public static readonly THRALL_DIST: HitDistribution = HitDistribution.linear(1.0, 0, THRALL_MAX);

readonly hits: WeightedHit[];

constructor(hits: WeightedHit[]) {
this.hits = hits;
}

private _thrallCumulative?: HitDistribution;

public get thrallCumulative() {
if (!this._thrallCumulative) {
this._thrallCumulative = this.zip(HitDistribution.THRALL_DIST).cumulative();
}
return this._thrallCumulative;
}

public addHit(w: WeightedHit): void {
this.hits.push(w);
}
Expand Down
74 changes: 35 additions & 39 deletions src/lib/PlayerVsNPCCalc.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ import {
OLM_MAGE_HAND_IDS,
OLM_MELEE_HAND_IDS, ONE_HIT_MONSTERS, SECONDS_PER_TICK,
TEKTON_IDS,
THRALL_DPT, THRALL_SPEED,
TOMBS_OF_AMASCUT_MONSTER_IDS, TTK_DIST_EPSILON, TTK_DIST_MAX_ITER_ROUNDS,
USES_DEFENCE_LEVEL_FOR_MAGIC_DEFENCE_NPC_IDS,
VERZIK_P1_IDS,
Expand Down Expand Up @@ -1038,7 +1039,8 @@ export default class PlayerVsNPCCalc extends BaseCalc {
* Returns the expected damage per tick, based on the player's attack speed.
*/
public getDpt() {
return this.getDistribution().getExpectedDamage() / this.getAttackSpeed();
const playerDpt = this.getDistribution().getExpectedDamage() / this.getAttackSpeed();
return this.player.buffs.thrallSpell ? playerDpt + THRALL_DPT : playerDpt;
}

/**
Expand All @@ -1048,37 +1050,11 @@ export default class PlayerVsNPCCalc extends BaseCalc {
return this.getDpt() / SECONDS_PER_TICK;
}

/**
* Returns the average hits-to-kill calculation.
*/
public getHtk() {
const dist = this.getDistribution();
const hist = dist.asHistogram();
const max = dist.getMax();
if (max === 0) {
return 0;
}

const htk = new Float64Array(this.monster.skills.hp + 1); // 0 hits left to do if hp = 0

for (let hp = 1; hp <= this.monster.skills.hp; hp++) {
let val = 1.0; // takes at least one hit
for (let hit = 1; hit <= Math.min(hp, max); hit++) {
const p = hist[hit];
val += p.chance * htk[hp - hit];
}

htk[hp] = val / (1 - hist[0].chance);
}

return htk[this.monster.skills.hp];
}

/**
* Returns the average time-to-kill (in seconds) calculation.
*/
public getTtk() {
return this.getHtk() * this.getAttackSpeed() * SECONDS_PER_TICK;
return this.monster.inputs.monsterCurrentHp / this.getDps();
}

/**
Expand All @@ -1088,8 +1064,8 @@ export default class PlayerVsNPCCalc extends BaseCalc {
*/
public getTtkDistribution(): Map<number, number> {
const speed = this.getAttackSpeed();
const dist = this.getDistribution().singleHitsplat;
if (dist.expectedHit() === 0) {
const baseDist = this.getDistribution().singleHitsplat;
if (baseDist.expectedHit() === 0 && !this.player.buffs.thrallSpell) {
return new Map<number, number>();
}

Expand All @@ -1104,25 +1080,46 @@ export default class PlayerVsNPCCalc extends BaseCalc {
// sum of non-zero-health probabilities
let epsilon = 1.0;

// if the hit dist depends on hp, we'll have to recalculate it each time, so cache the results to not repeat work
// if the hit baseDist depends on hp, we'll have to recalculate it each time, so cache the results to not repeat work
const recalcDistOnHp = PlayerVsNPCCalc.distIsCurrentHpDependent(this.player, this.monster);
const hpHitDists = new Map<number, HitDistribution>();
hpHitDists.set(this.monster.skills.hp, dist);
const hpHitDists = recalcDistOnHp ? new Array<HitDistribution>(this.monster.skills.hp) : [];
if (recalcDistOnHp) {
hpHitDists[this.monster.skills.hp] = baseDist;
for (let hp = 0; hp < this.monster.skills.hp; hp++) {
hpHitDists.set(hp, this.distAtHp(hp));
hpHitDists[hp] = this.distAtHp(hp);
}
}

// some utils for determining what hit dist to use per tick/hp combo
// everything here should be cached some way or another
const playerAttacksThisTick = (tick: number): boolean => tick % speed === 0;
const thrallAttacksThisTick = (tick: number): boolean => this.player.buffs.thrallSpell && (tick % THRALL_SPEED === 0);
const distAtHp = (tick: number, hp: number): HitDistribution | undefined => {
const playerDist = hpHitDists[hp] || baseDist;
if (playerAttacksThisTick(tick) && thrallAttacksThisTick(tick)) {
return playerDist.thrallCumulative;
} if (playerAttacksThisTick(tick)) {
return playerDist;
} if (thrallAttacksThisTick(tick)) {
return HitDistribution.THRALL_DIST;
}
return undefined;
};

// 1. until the amount of hp values remaining above zero is more than our desired epsilon accuracy,
// or we reach the maximum iteration rounds
for (let hit = 0; hit < (TTK_DIST_MAX_ITER_ROUNDS + 1) && epsilon >= TTK_DIST_EPSILON; hit++) {
const maxTicks = TTK_DIST_MAX_ITER_ROUNDS * this.getAttackSpeed();
// eslint-disable-next-line no-labels
outer: for (let tick = 0; tick < maxTicks && epsilon >= TTK_DIST_EPSILON; tick++) {
const nextHps = new Float64Array(this.monster.skills.hp + 1);

// 3. for each possible hp value,
for (const [hp, hpProb] of hps.entries()) {
// this is a bit of a hack, but idk if there's a better way
const currDist: HitDistribution = recalcDistOnHp ? hpHitDists.get(hp)! : dist;
const currDist = distAtHp(tick, hp);
if (!currDist) {
// eslint-disable-next-line no-labels
continue outer;
}

// 4. for each damage amount possible,
for (const h of currDist.hits) {
Expand All @@ -1140,8 +1137,7 @@ export default class PlayerVsNPCCalc extends BaseCalc {
// 6. if the hp we are about to arrive at is <= 0, the npc is killed, the iteration count is hits done,
// and we add this probability path into the delta
if (newHp <= 0) {
const tick = hit * speed + 1;
ttks.set(tick, (ttks.get(tick) || 0) + chanceOfAction);
ttks.set(tick + 1, (ttks.get(tick + 1) || 0) + chanceOfAction);
epsilon -= chanceOfAction;
} else {
// 7. otherwise, we add the chance of this path to the next iteration's hp value
Expand Down
5 changes: 5 additions & 0 deletions src/lib/constants.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { CombatStyleStance } from '@/types/PlayerCombatStyle';
import { range, sum } from 'd3-array';

export const AKKHA_IDS = [
11789, 11790, 11791, 11792, 11793, 11794, 11795, 11796,
Expand Down Expand Up @@ -328,3 +329,7 @@ export const SECONDS_PER_TICK = 0.6;

export const TTK_DIST_MAX_ITER_ROUNDS = 1000;
export const TTK_DIST_EPSILON = 0.0001;

export const THRALL_MAX = 3;
export const THRALL_SPEED = 4;
export const THRALL_DPT = sum(range(0, THRALL_MAX + 1)) / ((THRALL_MAX + 1) * THRALL_SPEED);
Binary file added src/public/img/misc/arceuus.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
4 changes: 2 additions & 2 deletions src/state.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -50,9 +50,8 @@ const generateInitialEquipment = () => {
return initialEquipment;
};

export const generateEmptyPlayer = (name?: string) => ({
export const generateEmptyPlayer = (name?: string): Player => ({
name: name ?? 'Loadout 1',
username: '',
style: getCombatStylesForCategory(EquipmentCategory.NONE)[0],
skills: {
atk: 99,
Expand Down Expand Up @@ -105,6 +104,7 @@ export const generateEmptyPlayer = (name?: string) => ({
markOfDarknessSpell: false,
forinthrySurge: false,
soulreaperStacks: 0,
thrallSpell: false,
},
spell: null,
});
Expand Down
4 changes: 4 additions & 0 deletions src/types/Player.ts
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,10 @@ export interface Player extends EquipmentStats {
onSlayerTask: boolean;
inWilderness: boolean;
forinthrySurge: boolean;
/**
* Whether the player is using thralls.
*/
thrallSpell: boolean;
/**
* Soul Stacks for the Soulreaper axe.
@see https://oldschool.runescape.wiki/w/Soulreaper_axe
Expand Down

0 comments on commit ebeac41

Please sign in to comment.