Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
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
20 changes: 20 additions & 0 deletions src/app/components/player/ExtraOptions.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import forinthry_surge from '@/public/img/misc/forinthry_surge.webp';
import soulreaper_axe from '@/public/img/misc/soulreaper_axe.png';
import ba_attacker from '@/public/img/misc/ba_attacker.webp';
import chinchompa from '@/public/img/misc/chinchompa.png';
import soulflame from '@/public/img/misc/Soulflame_horn.png';
import NumberInput from '@/app/components/generic/NumberInput';
import Toggle from '../generic/Toggle';

Expand Down Expand Up @@ -94,6 +95,25 @@ const ExtraOptions: React.FC = observer(() => {
</>
)}
/>
<Toggle
checked={player.buffs.soulflameHorn}
setChecked={(c) => store.updatePlayer({ buffs: { soulflameHorn: c } })}
label={(
<>
<img src={soulflame.src} width={18} className="inline-block" alt="" />
{' '}
Buffed by Soulflame horn
Comment thread
jmyaeger marked this conversation as resolved.
Outdated
{' '}
<span
className="align-super underline decoration-dotted cursor-help text-xs text-gray-300"
data-tooltip-id="tooltip"
data-tooltip-content="Next melee attack within 6 ticks is guaranteed to be accurate."
>
?
</span>
</>
)}
/>
<div className="w-full">
<NumberInput
className="form-control w-12"
Expand Down
102 changes: 60 additions & 42 deletions src/lib/PlayerVsNPCCalc.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1090,6 +1090,10 @@ export default class PlayerVsNPCCalc extends BaseCalc {
return hitChance;
}

if (this.player.buffs.soulflameHorn && this.isUsingMeleeStyle()) {
return this.track(DetailKey.PLAYER_ACCURACY_FINAL, 1.0);
}

const atk = this.getMaxAttackRoll();
const def = this.getNPCDefenceRoll();

Expand Down Expand Up @@ -1185,7 +1189,7 @@ export default class PlayerVsNPCCalc extends BaseCalc {
let ret: number = 0;
if (this.opts.usingSpecialAttack) {
if (this.wearing(['Bone claws', 'Burning claws']) && !this.isImmuneToNormalBurns()) {
ret = burningClawDoT(this.getHitChance());
ret = burningClawDoT(this.getHitChance(), this.player.buffs.soulflameHorn);
} else if (this.wearing('Scorching bow') && !this.isImmuneToNormalBurns()) {
ret = this.monster.attributes.includes(MonsterAttribute.DEMON) ? 5 : 1;
}
Expand Down Expand Up @@ -1255,6 +1259,7 @@ export default class PlayerVsNPCCalc extends BaseCalc {
const acc = this.getHitChance();
const [min, max] = this.getMinAndMax();
const style = this.player.style.type;
const soulflameHornBuff = this.player.buffs.soulflameHorn;

if (max === 0) {
return new AttackDistribution([new HitDistribution([new WeightedHit(1.0, [Hitsplat.INACCURATE])])]);
Expand Down Expand Up @@ -1327,17 +1332,6 @@ export default class PlayerVsNPCCalc extends BaseCalc {
}
}

let accurateZeroApplicable: boolean = true;
if (this.opts.usingSpecialAttack) {
if (this.wearing('Dragon claws')) {
accurateZeroApplicable = false;
dist = dClawDist(acc, max);
} else if (this.wearing(['Bone claws', 'Burning claws'])) {
accurateZeroApplicable = false;
dist = burningClawSpec(acc, max);
}
}

if (this.opts.usingSpecialAttack && this.wearing(['Dragon halberd', 'Crystal halberd']) && this.monster.size > 1) {
const secondHitAttackRoll = Math.trunc(this.getMaxAttackRoll() * 3 / 4);
const secondHitAcc = this.noInitSubCalc(
Expand All @@ -1363,23 +1357,6 @@ export default class PlayerVsNPCCalc extends BaseCalc {
}
}

if (this.opts.usingSpecialAttack && this.wearing('Abyssal dagger')) {
const secondHit = HitDistribution.linear(1.0, min, max);
dist = dist.transform((h) => new HitDistribution([new WeightedHit(1.0, [h])]).zip(secondHit), { transformInaccurate: false });
}

if (this.opts.usingSpecialAttack && this.wearing('Saradomin sword')) {
const magicHit = HitDistribution.linear(1.0, 1, 16);
dist = dist.transform(
(h) => {
if (h.accurate && !IMMUNE_TO_MAGIC_DAMAGE_NPC_IDS.includes(this.monster.id)) {
return new HitDistribution([new WeightedHit(1.0, [h])]).zip(magicHit);
}
return new HitDistribution([new WeightedHit(1.0, [h, Hitsplat.INACCURATE])]);
},
);
}

if (this.opts.usingSpecialAttack && this.wearing('Purging staff')) {
// todo(wgs): does this require the correct runes or only the level of each demonbane spell?
}
Expand Down Expand Up @@ -1413,19 +1390,6 @@ export default class PlayerVsNPCCalc extends BaseCalc {
dist = new AttackDistribution(hits);
}

if (this.isUsingMeleeStyle() && this.wearing('Dual macuahuitl')) {
const secondHit = HitDistribution.linear(acc, 0, max - Math.trunc(max / 2));
const firstHit = new AttackDistribution([HitDistribution.linear(acc, 0, Math.trunc(max / 2))]);
dist = firstHit.transform(
(h) => {
if (h.accurate) {
return new HitDistribution([new WeightedHit(1.0, [h])]).zip(secondHit);
}
return new HitDistribution([new WeightedHit(1.0, [h, Hitsplat.INACCURATE])]);
},
);
}

if (this.isUsingMeleeStyle() && this.isWearingTwoHitWeapon()) {
dist = new AttackDistribution([
HitDistribution.linear(acc, 0, Math.trunc(max / 2)),
Expand Down Expand Up @@ -1555,6 +1519,60 @@ export default class PlayerVsNPCCalc extends BaseCalc {
}
}

if (this.isUsingMeleeStyle() && soulflameHornBuff) {
// Only the first hit is affected by the buff
let firstHitDist = new HitDistribution(dist.dists[0].hits.filter((h) => h.anyAccurate()));
firstHitDist = firstHitDist.scaleProbability(1 / acc);
const allDists = dist.dists.length > 1 ? [firstHitDist, ...dist.dists.slice(1)] : [firstHitDist];
dist = new AttackDistribution(allDists);
}

// This comes after soulflame because the magic hit depends on the melee hit's accuracy
if (this.opts.usingSpecialAttack && this.wearing('Saradomin sword')) {
const magicHit = HitDistribution.linear(1.0, 1, 16);
dist = dist.transform(
(h) => {
if (h.accurate && !IMMUNE_TO_MAGIC_DAMAGE_NPC_IDS.includes(this.monster.id)) {
return new HitDistribution([new WeightedHit(1.0, [h])]).zip(magicHit);
}
return new HitDistribution([new WeightedHit(1.0, [h, Hitsplat.INACCURATE])]);
},
);
}

// Same with the abyssal dagger spec
if (this.opts.usingSpecialAttack && this.wearing('Abyssal dagger')) {
const secondHit = HitDistribution.linear(1.0, min, max);
dist = dist.transform((h) => new HitDistribution([new WeightedHit(1.0, [h])]).zip(secondHit), { transformInaccurate: false });
}

// And same with the dual macuahuitl
if (this.isUsingMeleeStyle() && this.wearing('Dual macuahuitl')) {
const secondHit = HitDistribution.linear(acc, 0, max - Math.trunc(max / 2));
const firstHitAcc = soulflameHornBuff ? 1.0 : acc;
const firstHit = new AttackDistribution([HitDistribution.linear(firstHitAcc, 0, Math.trunc(max / 2))]);
dist = firstHit.transform(
(h) => {
if (h.accurate) {
return new HitDistribution([new WeightedHit(1.0, [h])]).zip(secondHit);
}
return new HitDistribution([new WeightedHit(1.0, [h, Hitsplat.INACCURATE])]);
},
);
}

// Claws are after soulflame because the second accuracy roll is affected instead of the first
let accurateZeroApplicable: boolean = true;
if (this.opts.usingSpecialAttack) {
if (this.wearing('Dragon claws')) {
accurateZeroApplicable = false;
dist = dClawDist(acc, max, soulflameHornBuff);
} else if (this.wearing(['Bone claws', 'Burning claws'])) {
accurateZeroApplicable = false;
dist = burningClawSpec(acc, max, soulflameHornBuff);
}
}

if (this.player.spell && this.player.spell.max_hit === 0) {
// don't raise things like bind
accurateZeroApplicable = false;
Expand Down
26 changes: 18 additions & 8 deletions src/lib/dists/claws.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,20 +3,20 @@ import {
} from '@/lib/HitDist';
import { sum } from 'd3-array';

const generateTotals = (accRoll: number, totalRolls: number, acc: number, max: number, highOffset: number): [chance: number, low: number, high: number] => {
const generateTotals = (accRoll: number, totalRolls: number, acc: number, max: number, highOffset: number, soulflameHornBuff: boolean): [chance: number, low: number, high: number] => {
const low = Math.trunc(max * (totalRolls - accRoll) / 4);
const high = max + low + highOffset;
const chancePreviousRollsFail = (1 - acc) ** accRoll;
const chanceThisRollPasses = chancePreviousRollsFail * acc;
const chanceThisRollPasses = (soulflameHornBuff && accRoll === 1) ? chancePreviousRollsFail : chancePreviousRollsFail * acc;
const chancePerDmg = chanceThisRollPasses / (high - low + 1);

return [chancePerDmg, low, high];
};

export const dClawDist = (acc: number, max: number): AttackDistribution => {
export const dClawDist = (acc: number, max: number, soulflameHornBuff: boolean): AttackDistribution => {
const dist = new HitDistribution([]);
for (let accRoll = 0; accRoll < 4; accRoll++) {
const [chancePerDmg, low, high] = generateTotals(accRoll, 4, acc, max, -1);
const [chancePerDmg, low, high] = generateTotals(accRoll, 4, acc, max, -1, soulflameHornBuff);
for (let dmg = low; dmg <= high; dmg++) {
switch (accRoll) {
case 0:
Expand Down Expand Up @@ -56,6 +56,9 @@ export const dClawDist = (acc: number, max: number): AttackDistribution => {
break;
}
}
if (accRoll === 1 && soulflameHornBuff) {
return new AttackDistribution([dist]);
}
}

const chanceAllFail = (1 - acc) ** 4;
Expand All @@ -74,10 +77,10 @@ export const dClawDist = (acc: number, max: number): AttackDistribution => {
return new AttackDistribution([dist]);
};

export const burningClawSpec = (acc: number, max: number): AttackDistribution => {
export const burningClawSpec = (acc: number, max: number, soulflameHornBuff: boolean): AttackDistribution => {
const dist = new HitDistribution([]);
for (let accRoll = 0; accRoll < 3; accRoll++) {
const [chancePerDmg, low, high] = generateTotals(accRoll, 3, acc, max, 0);
const [chancePerDmg, low, high] = generateTotals(accRoll, 3, acc, max, 0, soulflameHornBuff);
for (let dmg = low; dmg <= high; dmg++) {
switch (accRoll) {
case 0:
Expand Down Expand Up @@ -105,6 +108,9 @@ export const burningClawSpec = (acc: number, max: number): AttackDistribution =>
break;
}
}
if (accRoll === 1 && soulflameHornBuff) {
return new AttackDistribution([dist]);
}
}

const chanceAllFail = (1 - acc) ** 3;
Expand Down Expand Up @@ -155,14 +161,18 @@ export const BURN_EXPECTED = [0, 1, 2].map((accRoll) => sum(BURN_MATRIX, (row) =
return chanceOfRow * damage;
}));

export const burningClawDoT = (acc: number): number => {
export const burningClawDoT = (acc: number, soulflameHornBuff: boolean): number => {
// 10 damage burn x3 hitsplats, 15/30/45% chance per splat dependent on which roll hits
let accumulator = 0;

for (let accRoll = 0; accRoll < 3; accRoll++) {
const prevRollsFail = (1 - acc) ** accRoll;
const thisRollHits = prevRollsFail * acc;

if (soulflameHornBuff && accRoll === 1) {
return accumulator + prevRollsFail * BURN_EXPECTED[accRoll];
}

const thisRollHits = prevRollsFail * acc;
accumulator += thisRollHits * BURN_EXPECTED[accRoll];
}
return accumulator;
Expand Down
Binary file added src/public/img/misc/Soulflame_horn.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
1 change: 1 addition & 0 deletions src/state.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -131,6 +131,7 @@ export const generateEmptyPlayer = (name?: string): Player => ({
baAttackerLevel: 0,
chinchompaDistance: 4, // 4 tiles is the optimal range for "medium fuse" (rapid), which is the default selected stance
usingSunfireRunes: false,
soulflameHorn: false,
},
spell: null,
});
Expand Down
6 changes: 6 additions & 0 deletions src/types/Player.ts
Original file line number Diff line number Diff line change
Expand Up @@ -141,6 +141,12 @@ export interface Player extends EquipmentStats {
* @see https://oldschool.runescape.wiki/w/Sunfire_rune
*/
usingSunfireRunes: boolean;
/**
* Whether the player is under the Entice effect of the Soulflame horn,
* which makes the next melee attack within 6 ticks guaranteed to hit.
* @see https://oldschool.runescape.wiki/w/Soulflame_horn
*/
soulflameHorn: boolean;
};
spell: Spell | null;
}