From b582606e088b21c54c4abd4f73aee802e9c2857a Mon Sep 17 00:00:00 2001 From: Jonathan Yaeger Date: Fri, 16 May 2025 22:44:18 -0400 Subject: [PATCH 1/3] Add soulflame horn buff --- src/app/components/player/ExtraOptions.tsx | 20 ++++ src/lib/PlayerVsNPCCalc.ts | 102 ++++++++++++--------- src/lib/dists/claws.ts | 26 ++++-- src/public/img/misc/Soulflame_horn.png | Bin 0 -> 453 bytes src/state.tsx | 1 + src/types/Player.ts | 6 ++ 6 files changed, 105 insertions(+), 50 deletions(-) create mode 100644 src/public/img/misc/Soulflame_horn.png diff --git a/src/app/components/player/ExtraOptions.tsx b/src/app/components/player/ExtraOptions.tsx index f00d57b58..8661babbb 100644 --- a/src/app/components/player/ExtraOptions.tsx +++ b/src/app/components/player/ExtraOptions.tsx @@ -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'; @@ -94,6 +95,25 @@ const ExtraOptions: React.FC = observer(() => { )} /> + store.updatePlayer({ buffs: { soulflameHorn: c } })} + label={( + <> + + {' '} + Buffed by Soulflame horn + {' '} + + ? + + + )} + />
1) { const secondHitAttackRoll = Math.trunc(this.getMaxAttackRoll() * 3 / 4); const secondHitAcc = this.noInitSubCalc( @@ -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? } @@ -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)), @@ -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; diff --git a/src/lib/dists/claws.ts b/src/lib/dists/claws.ts index 8e2f65c0b..770ac69a8 100644 --- a/src/lib/dists/claws.ts +++ b/src/lib/dists/claws.ts @@ -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: @@ -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; @@ -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: @@ -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; @@ -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; diff --git a/src/public/img/misc/Soulflame_horn.png b/src/public/img/misc/Soulflame_horn.png new file mode 100644 index 0000000000000000000000000000000000000000..ab95e5d65b0b36d1fa646565ad22cc5821fe5140 GIT binary patch literal 453 zcmV;$0XqJPP)99TBJ^ z6@VHIgc=Qz9TBS}6_p?nJP81?Bo&Sw5uzdycvMk9EGmFlR&Y&BdsR|vNl0^0O?6RF zeOFaTGB8{}JwgotLoP0EOiEHYH)2CVSUo&mLO^9kLuW`v(4<=t00001bW%=J06^y0 zW&i*H<4Ht8RCwBT(#N)gAQ%MTjDTWGFy;1}qQ*vh|F;Wfr{KR}^PQm_X15;fHU}EO z291sGy0F6h)omN;kiy=Y`g)g91mB+a(bTuQGzy;e>S2v(n|8EV8>3_xOg&2D8;L1l z|29&Szb8p%WGN5ag_cQtK4m2IhST};ayiftRaGT~hyyX?JWV-IXozy|C=JAb+<_bF zQ?Yke9`;3sb*Lz^{P)K#d`-viqxcY2;ZDRdgLx-jCWQ4p7GjNg6Z?Xj#ZMb4waz&6 vivrzEO4}jW0u=$1dLq=Gxf)x&!)9AwczF=nMzPeI00000NkvXXu0mjf$L^=v literal 0 HcmV?d00001 diff --git a/src/state.tsx b/src/state.tsx index 9500b9fec..4efc7b92e 100644 --- a/src/state.tsx +++ b/src/state.tsx @@ -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, }); diff --git a/src/types/Player.ts b/src/types/Player.ts index fdbf184a7..f50dcd882 100644 --- a/src/types/Player.ts +++ b/src/types/Player.ts @@ -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; } From 824355e311c7a80a72a83afca9a43dc888340717 Mon Sep 17 00:00:00 2001 From: Jonathan Yaeger Date: Mon, 19 May 2025 21:34:17 -0400 Subject: [PATCH 2/3] Rename buff in extra options --- src/app/components/player/ExtraOptions.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/app/components/player/ExtraOptions.tsx b/src/app/components/player/ExtraOptions.tsx index 8661babbb..70d537bd9 100644 --- a/src/app/components/player/ExtraOptions.tsx +++ b/src/app/components/player/ExtraOptions.tsx @@ -102,7 +102,7 @@ const ExtraOptions: React.FC = observer(() => { <> {' '} - Buffed by Soulflame horn + Soulflame horn buff {' '} Date: Sun, 3 Aug 2025 15:16:58 -0400 Subject: [PATCH 3/3] 10 ticks (6 seconds) --- src/app/components/player/ExtraOptions.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/app/components/player/ExtraOptions.tsx b/src/app/components/player/ExtraOptions.tsx index 70d537bd9..72c8422f7 100644 --- a/src/app/components/player/ExtraOptions.tsx +++ b/src/app/components/player/ExtraOptions.tsx @@ -107,7 +107,7 @@ const ExtraOptions: React.FC = observer(() => { ?