Last active
December 22, 2023 03:54
-
-
Save SimDing/a067718caffbdad042ed3b6c8d0d77ff to your computer and use it in GitHub Desktop.
Baldurs Gate 3 - Great Weapon Fighting and Savage Attacker Calculations
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
// DSL for producing distributions of dice rolls | |
type Cont<T> = (state: T) => void; | |
const rollDie = <T, U>(faces: number, state: T, cont: (faces: number, state: T, next: Cont<U>) => void, next: Cont<U>) => { | |
for (let i = 1; i <= faces; i++) { | |
//console.log(state, 'rolled', i); | |
cont(i, { ...state }, next); | |
} | |
}; | |
const cWhile = <T>(cond: (state: T) => boolean, state: T, inner: (state: T, next: Cont<T>) => void, next: Cont<T>) => { | |
if (cond(state)) { | |
inner(state, state => cWhile(cond, state, inner, next)); | |
} else { | |
next(state); | |
} | |
}; | |
const forOf = <T, V>(arr: V[], state: T, inner: (v: V, state: T, next: Cont<T>) => void, next: Cont<T>) => { | |
arr.reduceRight((a, b) => state => inner(b, state, a), next)(state); | |
}; | |
const cif = <T>(cond: boolean, state: T, cont: (state: T, next: Cont<T>) => void, next: Cont<T>) => { | |
if (cond) { | |
cont(state, next); | |
} else { | |
next(state); | |
} | |
}; | |
interface Attack { | |
name: string; | |
dice: number[]; // damage dice | |
gwf: boolean; // great weapon fighting | |
sa: boolean; // savage attacker | |
flat: number; // bonus to damage throw | |
ac: number; // armor class of target | |
ab: number; // bonus to attack throw | |
critRange: number; // 1: 20, 2: 19-20, 3: 18-20... | |
onlyDmg?: boolean; // skip hit / crit calculation | |
} | |
const convolution = (f: ArrayLike<number>, g: ArrayLike<number>) => { | |
const result = new Float64Array(f.length + g.length - 1); | |
for (let k = 0; k < result.length; k++) { | |
for (let i = Math.max(0, k - g.length + 1); i < Math.min(k + 1, f.length); i++) { | |
const fval = f[i]; | |
const gx = k - i; | |
const gval = g[gx]; | |
result[k] += fval * gval; | |
} | |
} | |
return result; | |
}; | |
const normalize = (arr: Float64Array) => { | |
const sum = arr.reduce((a, b) => a + b); | |
for (let k = 0; k < arr.length; k++) { | |
arr[k] /= sum; | |
} | |
}; | |
const mulAdd = (mul: number, from: Float64Array, into: Float64Array) => { | |
for (let k = 0; k < from.length; k++) { | |
into[k] += mul * from[k]; | |
} | |
}; | |
const simulateAttack = (attack: Attack) => { | |
// The damage roll | |
let result = new Float64Array(attack.flat + 1); // read flat: 100% | |
result[attack.flat] = 1; | |
for (const die of attack.dice) { | |
/* | |
let result = rollDie(die); | |
if (attack.gwf && result < 3) { | |
result = rollDie(die); | |
} | |
if (attack.sa) { | |
result = Math.max(result, rollDie(die)); | |
} | |
stats[result] += 1; | |
*/ | |
const stats = new Float64Array(die + 1); | |
rollDie(die, null, (rolled, _, next) => { | |
next({ result: rolled }); | |
}, (state: { result: number }) => { | |
cif(attack.gwf, state, (state, next) => { | |
rollDie(die, state, (rolled, state, next) => { | |
next( { result: state.result < 3 ? rolled : state.result }); | |
}, next); | |
}, state => { | |
cif(attack.sa, state, (state, next) => { | |
rollDie(die, state, (rolled, state, next) => { | |
next({ result: Math.max(rolled, state.result) }); | |
}, next); | |
}, state => { | |
stats[state.result] += 1; | |
}); | |
}); | |
}); | |
result = convolution(result, stats); | |
normalize(result); | |
} | |
if (attack.onlyDmg) { | |
return result; | |
} | |
// crit / miss / hit | |
const critResult = convolution(result, result); | |
const finalResult = new Float64Array(critResult.length); | |
mulAdd(attack.critRange, critResult, finalResult); | |
const minRoll = Math.max(attack.ac - attack.ab, 2); | |
const missCases = minRoll - 1; | |
mulAdd(missCases, new Float64Array([1]), finalResult); | |
const hitCases = 20 - missCases - attack.critRange; | |
mulAdd(hitCases, result, finalResult); | |
normalize(finalResult); | |
return finalResult; | |
}; | |
const consolidate = (stats: ArrayLike<number>) => { | |
let ex = 0; | |
let ex_sq = 0; | |
for (let i = 1; i < stats.length; i++) { | |
ex += i * stats[i]; | |
ex_sq += i * i * stats[i]; | |
} | |
const v = ex_sq - ex * ex; | |
return 'γ: ' + ex.toFixed(2) + ', σ: ' + Math.sqrt(v).toFixed(2); | |
}; | |
const compareAttacks = (attacks: Attack[]) => { | |
const stats = attacks.map(simulateAttack); | |
for (let i = 0; i < attacks.length; i++) { | |
console.log(attacks[i].name + ': ' + consolidate(stats[i])); | |
//console.log(stats[i]); | |
} | |
}; | |
// Example: paladin with Everburn Blade and Divine Strike | |
compareAttacks([{ | |
dice: [6, 6, 8, 8, 4], | |
gwf: false, | |
name: 'nothing', | |
sa: false, | |
flat: 3, | |
ac: 14, | |
ab: 3, | |
critRange: 1, | |
}, { | |
dice: [6, 6, 8, 8, 4], | |
gwf: false, | |
name: 'ability score improvement', | |
sa: false, | |
flat: 4, | |
ac: 14, | |
ab: 4, | |
critRange: 1, | |
}, { | |
dice: [6, 6, 8, 8, 4], | |
gwf: false, | |
name: '2x ability score improvement', | |
sa: false, | |
flat: 5, | |
ac: 14, | |
ab: 5, | |
critRange: 1, | |
}, { | |
dice: [6, 6, 8, 8, 4], | |
gwf: true, | |
name: 'gwf + ability score improvement', | |
sa: false, | |
flat: 4, | |
ac: 14, | |
ab: 4, | |
critRange: 1, | |
},{ | |
dice: [6, 6, 8, 8, 4], | |
gwf: false, | |
name: 'sa', | |
sa: true, | |
flat: 3, | |
ac: 14, | |
ab: 3, | |
critRange: 1, | |
}, { | |
dice: [6, 6, 8, 8, 4], | |
gwf: true, | |
name: 'gwf + sa', | |
sa: true, | |
flat: 3, | |
ac: 14, | |
ab: 3, | |
critRange: 1, | |
}]); | |
/* | |
nothing: γ: 11.83, σ: 13.05 | |
ability score improvement: γ: 13.50, σ: 13.52 | |
2x ability score improvement: γ: 15.27, σ: 13.87 | |
gwf + ability score improvement: γ: 15.50, σ: 15.31 | |
sa: γ: 14.68, σ: 15.94 | |
gwf + sa: γ: 15.49, σ: 16.75 | |
*/ |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment