diff --git a/src/app/components/player/DemonicPactsLeague.tsx b/src/app/components/player/DemonicPactsLeague.tsx index b0324257..72fb0c4a 100644 --- a/src/app/components/player/DemonicPactsLeague.tsx +++ b/src/app/components/player/DemonicPactsLeague.tsx @@ -116,7 +116,11 @@ const BlindbagSelector = observer(() => { const DemonicPactsLeague: React.FC = observer(() => { const [showCombatMasteriesUI, setShowCombatMasteriesUI] = useState(false); const store = useStore(); - const { cullingSpree } = store.player.leagues.six; + const { + cullingSpree, + minionEnabled, + minionZamorakItemCount, + } = store.player.leagues.six; const fromUrlInput = useRef(null); const fromUrlBtn = useRef(null); @@ -185,6 +189,31 @@ const DemonicPactsLeague: React.FC = observer(() => { )} /> + store.updatePlayer({ leagues: { six: { minionEnabled: checked } } })} + label="Minion" + /> + +
+ { + store.updatePlayer({ leagues: { six: { minionZamorakItemCount: value } } }); + }} + /> + + + Unique equippable Zamorak items consumed + +
+
{ + const minionCalc = this.getMinionTransformCalc(style); + const defenceRoll = style === 'ranged' + ? this.getNpcDefenceRollForStyle('ranged', rangedDefenceBonus) + : this.getNpcDefenceRollForStyle('magic'); + const accuracy = BaseCalc.getNormalAccuracyRoll(MINION_ATTACK_ROLL, defenceRoll); + const baseDist = HitDistribution.linear(accuracy, MINION_MIN_HIT, maxHit); + const finalDist = new AttackDistribution([baseDist]) + .transform(minionCalc.applyNpcTransforms(style)) + .singleHitsplat; + + return { + style, + defenceRoll, + accuracy, + dist: finalDist, + }; + }); + + const bestCandidate = candidateDists.reduce((best, current) => ( + current.dist.expectedHit() > best.dist.expectedHit() ? current : best + )); + + this.track(DetailKey.LEAGUES_MINION_STYLE, bestCandidate.style); + this.track(DetailKey.LEAGUES_MINION_DEFENCE_ROLL, bestCandidate.defenceRoll); + this.track(DetailKey.LEAGUES_MINION_ACCURACY, bestCandidate.accuracy); + this.trackDist(DetailKey.DIST_LEAGUES_MINION, bestCandidate.dist); + return bestCandidate.dist; + } + + private getMinionTransformCalc(styleType: 'ranged' | 'magic'): PlayerVsNPCCalc { + const minionPlayer = { + ...this.player, + style: { + name: styleType === 'magic' ? 'Minion Magic' : 'Minion Ranged', + type: styleType, + stance: styleType === 'magic' ? 'Autocast' : 'Accurate', + }, + prayers: [], + spell: null, + equipment: { + head: null, + cape: null, + neck: null, + ammo: null, + weapon: null, + body: null, + shield: null, + legs: null, + hands: null, + feet: null, + ring: null, + }, + buffs: { + ...this.player.buffs, + baAttackerLevel: 0, + kandarinDiary: false, + markOfDarknessSpell: false, + soulreaperStacks: 0, + usingSunfireRunes: false, + }, + }; + + const minionCalc = new PlayerVsNPCCalc(minionPlayer, this.monster, { + ...this.opts, + isLeaguesSubCalc: true, + loadoutName: `${this.opts.loadoutName}/Minion/${styleType}`, + noInit: true, + }); + minionCalc.allEquippedItems = []; + minionCalc.baseMonster = this.baseMonster; + return minionCalc; + } + + private getMinionExpectedDamage(): number { + return this.getMinionHitDistribution()?.expectedHit() ?? 0; + } + + private getMinionDpt(): number { + return this.getMinionExpectedDamage() / MINION_ATTACK_SPEED; + } + + protected getMinionDelayedHits(): DelayedHit[] { + const minionDist = this.getMinionHitDistribution(); + if (!minionDist) { + return []; + } + + return minionDist.withProbabilisticDelays(() => [[1.0, MINION_ATTACK_SPEED]]); } private getPlayerMaxMeleeAttackRoll(): number { @@ -2309,7 +2457,7 @@ export default class PlayerVsNPCCalc extends BaseCalc { * Returns the expected damage per tick, based on the player's attack speed. */ public getDpt() { - return this.getExpectedDamage() / this.getExpectedAttackSpeed(); + return (this.getExpectedDamage() / this.getExpectedAttackSpeed()) + this.getMinionDpt(); } /** @@ -2376,6 +2524,10 @@ export default class PlayerVsNPCCalc extends BaseCalc { * Returns the average time-to-kill (in seconds) calculation. */ public getTtk() { + if (this.player.leagues.six.minionEnabled) { + return sum(Array.from(this.getTtkDistribution().entries()), ([ticks, probability]) => ticks * probability) * SECONDS_PER_TICK; + } + return this.getHtk() * this.getExpectedAttackSpeed() * SECONDS_PER_TICK; } @@ -2474,6 +2626,80 @@ export default class PlayerVsNPCCalc extends BaseCalc { } } + const minionHits = this.getMinionDelayedHits(); + if (minionHits.length > 0) { + const minionStateHeight = iterMax + 20; + const minionStateWidth = this.monster.skills.hp + 1; + const minionTickHpsRoot = new Float64Array(minionStateHeight * minionStateWidth); + const minionTickHps = range(0, minionStateHeight) + .map((i) => minionTickHpsRoot.subarray(minionStateWidth * i, minionStateWidth * (i + 1))); + minionTickHps[1][this.monster.inputs.monsterCurrentHp || this.monster.skills.hp] = 1.0; + + const minionTtks = new Map(); + let minionEpsilon = 1.0; + + for (let tick = 1; tick <= iterMax && minionEpsilon >= TTK_DIST_EPSILON; tick++) { + const playerDue = ((tick - 1) % this.getAttackSpeed()) === 0; + const minionDue = ((tick - 1) % MINION_ATTACK_SPEED) === 0; + const hps = minionTickHps[tick]; + + if (!playerDue && !minionDue) { + for (const [hp, hpProb] of hps.entries()) { + if (hpProb !== 0) { + minionTickHps[tick + 1][hp] += hpProb; + } + } + continue; + } + + for (const [hp, hpProb] of hps.entries()) { + if (hpProb === 0) { + continue; + } + + let combinedDist: HitDistribution | null = null; + if (playerDue) { + combinedDist = (recalcDistOnHp ? hpHitDists[hp] : playerDist) + .map(([wh]) => wh) + .reduce((dist, wh) => { + dist.addHit(wh); + return dist; + }, new HitDistribution([])); + } + if (minionDue) { + const minionHitDist = minionHits + .map(([wh]) => wh) + .reduce((dist, wh) => { + dist.addHit(wh); + return dist; + }, new HitDistribution([])); + combinedDist = combinedDist ? combinedDist.zip(minionHitDist).cumulative() : minionHitDist; + } + + if (!combinedDist) { + continue; + } + + for (const wh of combinedDist.hits) { + const chanceOfAction = wh.probability * hpProb; + if (chanceOfAction === 0) { + continue; + } + + const newHp = hp - wh.getSum(); + if (newHp <= 0) { + minionTtks.set(tick, (minionTtks.get(tick) || 0) + chanceOfAction); + minionEpsilon -= chanceOfAction; + } else { + minionTickHps[tick + 1][newHp] += chanceOfAction; + } + } + } + } + + return minionTtks; + } + // todo dp backwards from 0 hp? // 1. until the amount of hp values remaining above zero is more than our desired epsilon accuracy, // or we reach the maximum iteration rounds diff --git a/src/state.tsx b/src/state.tsx index 73d5674d..12480f05 100644 --- a/src/state.tsx +++ b/src/state.tsx @@ -143,6 +143,8 @@ export const generateEmptyPlayer = (name?: string): Player => ({ six: { selectedNodeIds: new Set(['node1']), effects: {}, + minionEnabled: false, + minionZamorakItemCount: 0, distanceToEnemy: 1, enemyPrayers: { melee: false, diff --git a/src/tests/TtkDist.test.ts b/src/tests/TtkDist.test.ts index 4246176d..3b0205f7 100644 --- a/src/tests/TtkDist.test.ts +++ b/src/tests/TtkDist.test.ts @@ -5,6 +5,14 @@ import { import PlayerVsNPCCalc from '@/lib/PlayerVsNPCCalc'; import { getTestMonster, getTestPlayer } from '@/tests/utils/TestUtils'; +class TestMinionTtkCalc extends PlayerVsNPCCalc { + private readonly delayedHits = HitDistribution.single(1.0, [new Hitsplat(1)]).withProbabilisticDelays(() => [[1.0, 3]]); + + protected override getMinionDelayedHits() { + return this.delayedHits; + } +} + describe('variable attack speeds should not merge states from different timelines', () => { test('2hp, 50% accuracy, 3:4 guarantee, 1 max', () => { const dist = new AttackDistribution([new HitDistribution([ @@ -101,4 +109,29 @@ describe('variable attack speeds should not merge states from different timeline expect(result.get(83)) .toBeCloseTo(2.64e-05); }); + + test('minion and player hits can combine on the same tick', () => { + const m = getTestMonster('Abyssal demon', 'Standard', { + skills: { + hp: 2, + }, + }); + const p = getTestPlayer(m, { + attackSpeed: 4, + leagues: { + six: { + minionEnabled: true, + minionZamorakItemCount: 0, + }, + }, + }); + const calc = new TestMinionTtkCalc(p, m); + calc.getDistribution = () => new AttackDistribution([ + HitDistribution.single(1.0, [new Hitsplat(1)]), + ]); + + const result = calc.getTtkDistribution(); + expect(result.get(1)) + .toBeCloseTo(1.0); + }); }); diff --git a/src/tests/calc/Minion.test.ts b/src/tests/calc/Minion.test.ts new file mode 100644 index 00000000..60baa1c5 --- /dev/null +++ b/src/tests/calc/Minion.test.ts @@ -0,0 +1,128 @@ +import { describe, expect, test } from '@jest/globals'; +import PlayerVsNPCCalc from '@/lib/PlayerVsNPCCalc'; +import { DetailKey } from '@/lib/CalcDetails'; +import { + findEquipment, + findResult, + getTestMonster, + getTestPlayer, +} from '@/tests/utils/TestUtils'; + +const getMinionDpt = (calc: PlayerVsNPCCalc): number => calc.getDpt() - calc.getExpectedDamage() / calc.getExpectedAttackSpeed(); + +describe('Minion', () => { + test('chooses the weaker of ranged or magic defences', () => { + const m = getTestMonster('Abyssal demon', 'Standard', { + defensive: { + light: 220, + standard: 220, + heavy: 220, + magic: -20, + }, + }); + const p = getTestPlayer(m, { + equipment: { + weapon: findEquipment('Bronze dagger'), + }, + leagues: { + six: { + minionEnabled: true, + minionZamorakItemCount: 0, + }, + }, + }); + + const calc = new PlayerVsNPCCalc(p, m, { detailedOutput: true }); + calc.getDps(); + + expect(findResult(calc.details, DetailKey.LEAGUES_MINION_STYLE)).toBe('magic'); + }); + + test('gains max hit from consumed Zamorak items', () => { + const m = getTestMonster('Abyssal demon', 'Standard'); + const playerOverrides = { + equipment: { + weapon: findEquipment('Bronze dagger'), + }, + leagues: { + six: { + minionEnabled: true, + }, + }, + }; + + const baseCalc = new PlayerVsNPCCalc( + getTestPlayer(m, { + ...playerOverrides, + leagues: { + six: { + ...playerOverrides.leagues.six, + minionZamorakItemCount: 0, + }, + }, + }), + m, + { detailedOutput: true }, + ); + baseCalc.getDps(); + + const upgradedCalc = new PlayerVsNPCCalc( + getTestPlayer(m, { + ...playerOverrides, + leagues: { + six: { + ...playerOverrides.leagues.six, + minionZamorakItemCount: 5, + }, + }, + }), + m, + { detailedOutput: true }, + ); + upgradedCalc.getDps(); + + expect(findResult(baseCalc.details, DetailKey.LEAGUES_MINION_MAX_HIT)).toBe( + 10, + ); + expect( + findResult(upgradedCalc.details, DetailKey.LEAGUES_MINION_MAX_HIT), + ).toBe(20); + expect(upgradedCalc.getDps()).toBeGreaterThan(baseCalc.getDps()); + }); + + test('does not inherit player bolt effects', () => { + const m = getTestMonster('Abyssal demon', 'Standard'); + const neutralCalc = new PlayerVsNPCCalc( + getTestPlayer(m, { + equipment: { + weapon: findEquipment('Bronze dagger'), + }, + leagues: { + six: { + minionEnabled: true, + minionZamorakItemCount: 0, + }, + }, + }), + m, + ); + + const boltCalc = new PlayerVsNPCCalc( + getTestPlayer(m, { + equipment: { + weapon: findEquipment('Rune crossbow'), + ammo: findEquipment('Ruby bolts (e)'), + }, + leagues: { + six: { + minionEnabled: true, + minionZamorakItemCount: 0, + }, + }, + }), + m, + ); + + expect(getMinionDpt(boltCalc)).toBeCloseTo(getMinionDpt(neutralCalc)); + }); +}); diff --git a/src/types/Player.ts b/src/types/Player.ts index a7e1bec3..f83778f2 100644 --- a/src/types/Player.ts +++ b/src/types/Player.ts @@ -85,6 +85,10 @@ export interface LeaguesState { effects: { [k in LeaguesEffect]?: number }; + minionEnabled: boolean; + + minionZamorakItemCount: number; + distanceToEnemy: number; enemyPrayers: {