Skip to content

Commit

Permalink
[0.2.0] API Performance improvements and simulations (#12)
Browse files Browse the repository at this point in the history
## UI

- The Left Navigation Bar on Desktop has been revamped and will no longer scroll with the content.

## API

- Instead of generating data per toughness, instead build `lt`, `eq`, `gt` properties. Then map that to the different toughness values (Fixes: #13)
    - This should have no difference in the final result, however, should improve performance greatly
- If you try and generate data for attacks > 8, switch to a simulation of dice rolls rather than generating every permutation (Fixes: #13)
- Added Sentry to API to capture errors (Fixes: #11)

## Notes
Here is a table of the number of permutations as the attacks grow:

| Attacks | Permutations  |
|---------|---------------|
| 2       | 36            |
| 4       | 1 296         |
| 6       | 46 656        |
| 8       | 1 679 616     |
| 10      | 60 466 176    |
| 12      | 2 176 782 336 |

Because of this it becomes too expensive to get population permutations for attacks > 8. Hence why it switches over to doing `1 500 000` simulations instead. It should be accurate enough as the probabilities are limited to 2 dec. places on the UI anyway
  • Loading branch information
damonhook committed Feb 6, 2020
1 parent 64ec77d commit 9452622
Show file tree
Hide file tree
Showing 30 changed files with 776 additions and 269 deletions.
20 changes: 14 additions & 6 deletions api/controllers/statsController.ts
@@ -1,3 +1,5 @@
import { IFighterProbabilities } from 'api/types';

import Fighter from '../models/fighter';
import * as t from './statsController.types';

Expand All @@ -6,16 +8,22 @@ export class StatsController {
const fighterList = fighters.map(f => new Fighter(f.name, f.profile));
const minStr = Math.min(...fighterList.map(f => f.profile.strength));
const maxStr = Math.max(...fighterList.map(f => f.profile.strength));
const fighterProbabilitiesData: IFighterProbabilities[] = fighterList.map(fighter =>
fighter.getProbabilities(),
);
const toughnessList = this.range(Math.max(minStr - 1, 1), maxStr + 1);
const data = toughnessList.map(toughness => {
return fighterList.reduce<t.TMappedResult>(
(acc, fighter) => {
acc.results[fighter.name] = fighter.getProbabilities({ toughness });
const data = toughnessList.map(toughness =>
fighterList.reduce<t.TMappedResult>(
(acc, fighter, index) => {
const probabilityData = fighterProbabilitiesData[index];
if (fighter.profile.strength < toughness) acc.results[fighter.name] = probabilityData.lt;
else if (fighter.profile.strength === toughness) acc.results[fighter.name] = probabilityData.eq;
else if (fighter.profile.strength > toughness) acc.results[fighter.name] = probabilityData.gt;
return acc;
},
{ toughness, results: {} },
);
});
),
);
return { results: data.map(d => this.buildResult(d)) };
}

Expand Down
40 changes: 40 additions & 0 deletions api/models/counter.ts
@@ -0,0 +1,40 @@
export type TCount = { [damage: number]: number };

export interface ICounter {
lt: TCount;
eq: TCount;
gt: TCount;
}

export class Counter implements ICounter {
lt: TCount;
eq: TCount;
gt: TCount;

constructor() {
this.lt = {};
this.eq = {};
this.gt = {};
}

increment(variant: keyof ICounter, damage: number) {
this[variant][damage] = (this[variant][damage] || 0) + 1;
}

incrementLt(damage: number) {
this.increment('lt', damage);
}

incrementEq(damage: number) {
this.increment('eq', damage);
}

incrementGt(damage: number) {
this.increment('gt', damage);
}

toDict(): ICounter {
const { lt, eq, gt } = this;
return { lt, eq, gt };
}
}
120 changes: 67 additions & 53 deletions api/models/fighter.ts
@@ -1,13 +1,9 @@
import Combinatorics, { IPredictableGenerator } from 'js-combinatorics';
import Combinatorics, { IGenerator } from 'js-combinatorics';

import { IFighter, IFighterProbability, IProfile, ITarget, TVector } from '../types';
import { getMax, getMean } from '../utils/statsUtils';
import { IFighter, IFighterProbabilities, IProfile, TVector } from '../types';
import { Counter, ICounter, TCount } from './counter';
import { D6 } from './dice';

const generatePermutations = <T>(vector: T[], n = 1): IPredictableGenerator<T[]> => {
const cmb = Combinatorics.baseN(vector, n);
return cmb;
};
import SimulationGenerator from './generator';

class Fighter implements IFighter {
name: string;
Expand All @@ -18,63 +14,81 @@ class Fighter implements IFighter {
this.profile = this.parseProfile(profile);
}

getProbabilities(target: ITarget): IFighterProbability {
const permutations = this.getPermutationMatrix(target);
const numPermutations = permutations.length;
const counts = {};
let sum = 0;
permutations.forEach(vector => {
const result = vector.reduce((acc, point) => acc + point, 0);
sum += result;
counts[result] = (counts[result] || 0) + 1;
});
const buckets = Object.keys(counts)
.map(Number)
.sort((x, y) => x - y)
.map(damage => ({
damage,
count: counts[damage],
probability: Number(((counts[damage] * 100) / numPermutations).toFixed(2)),
}));
const metrics = {
max: getMax(Object.keys(counts).map(Number)),
mean: Number((sum / numPermutations).toFixed(2)),
getProbabilities(): IFighterProbabilities {
let permutationGenerator: IGenerator<TVector>;
if (this.profile.attacks >= 8) {
permutationGenerator = new SimulationGenerator(1500000, this.profile.attacks);
} else {
permutationGenerator = this.getDicePermutationMatrix();
}
const numPermutations = permutationGenerator.length;
const counts = this.getDamageCounts(permutationGenerator);
const metrics = this.getMetrics();
const buckets = {
lt: this.getBuckets(counts.lt, numPermutations, metrics.lt.max),
eq: this.getBuckets(counts.eq, numPermutations, metrics.eq.max),
gt: this.getBuckets(counts.gt, numPermutations, metrics.gt.max),
};
buckets.push({ damage: metrics.max + 1, count: 0, probability: 0 });
return {
buckets,
metrics,
lt: { buckets: buckets.lt, metrics: metrics.lt },
eq: { buckets: buckets.eq, metrics: metrics.eq },
gt: { buckets: buckets.gt, metrics: metrics.gt },
};
}

getReducedPermutations(target: ITarget): TVector {
return this.getPermutationMatrix(target).map(vector => vector.reduce((acc, point) => acc + point, 0));
private getDamageCounts(permutationGenerator: IGenerator<TVector>): ICounter {
const counts = new Counter();
permutationGenerator.forEach((vector: TVector) => {
counts.incrementLt(this.reduceVector(vector, 5));
counts.incrementEq(this.reduceVector(vector, 4));
counts.incrementGt(this.reduceVector(vector, 3));
});
return counts.toDict();
}

getPermutationMatrix(target: ITarget): IPredictableGenerator<TVector> {
private reduceVector(vector: TVector, rollTarget: 3 | 4 | 5): number {
return vector.reduce((acc, roll) => {
const { hit, crit } = this.profile.damage;
if (roll >= 6) return acc + crit;
if (roll >= rollTarget) return acc + hit;
return acc;
}, 0);
}

private getMetrics() {
const { attacks, damage } = this.profile;
const { hit, crit } = damage;
const rollVector = D6.getRollVector();
const rollTarget = this.getRollTarget(target);
const resultVector = rollVector.map(roll => {
if (roll === 6) return crit;
if (roll >= rollTarget) return hit;
return 0;
});
return generatePermutations(resultVector, attacks);
const max = attacks * damage.crit;
return {
lt: { max, mean: this.getMean(5) },
eq: { max, mean: this.getMean(4) },
gt: { max, mean: this.getMean(3) },
};
}

getDicePermutationMatrix(): IPredictableGenerator<TVector> {
const { attacks } = this.profile;
return generatePermutations(D6.getRollVector(), attacks);
private getMean(rollTarget: 3 | 4 | 5) {
const { attacks, damage } = this.profile;
const hitChance = (D6.sides - rollTarget) / D6.sides;
const critChance = 1 / D6.sides;
const mean = attacks * (hitChance * damage.hit + critChance * damage.crit);
return Number(mean.toFixed(2));
}

private getBuckets(counts: TCount, numPermutations: number, max: number) {
const buckets = Object.keys(counts)
.map(Number)
.sort((x, y) => x - y)
.map(damage => ({
damage,
count: counts[damage],
probability: Number(((counts[damage] * 100) / numPermutations).toFixed(2)),
}));
buckets.push({ damage: max + 1, count: 0, probability: 0 });
return buckets;
}

getRollTarget(target: ITarget): number {
const { strength } = this.profile;
const { toughness } = target;
if (strength > toughness) return 3;
if (strength === toughness) return 4;
return 5;
private getDicePermutationMatrix(): IGenerator<TVector> {
const { attacks } = this.profile;
return Combinatorics.baseN(D6.getRollVector(), attacks);
}

private parseProfile(profile: IProfile): IProfile {
Expand Down
78 changes: 78 additions & 0 deletions api/models/generator.ts
@@ -0,0 +1,78 @@
import 'core-js/stable';
import 'regenerator-runtime/runtime';

import { TVector } from 'api/types';
import { IGenerator } from 'js-combinatorics';

import { D6 } from './dice';

class SimulationGenerator implements IGenerator<TVector> {
private generator: Generator<TVector, void, unknown>;
private numSimulations: number;
private numAttacks: number;
length: number;

constructor(numSimulations: number, numAttacks: number) {
this.numSimulations = numSimulations;
this.length = numSimulations;
this.numAttacks = numAttacks;
this.init();
}

next(): TVector {
const n = this.generator.next();
if (n.done) return undefined;
return n.value as TVector;
}

forEach(f: (item: TVector) => void): void {
this.init();
let n = this.next();
while (n) {
f(n);
n = this.next();
}
this.init();
}

map<TResult>(f: (item: TVector) => TResult): TResult[] {
return this.toArray().map(f);
}

filter(predicate: (item: TVector) => boolean): TVector[] {
this.init();
let n = this.next();
const result = [];
while (n) {
if (predicate(n)) result.push(n);
n = this.next();
}
this.init();
return result;
}

toArray() {
this.init();
let n = this.next();
const result = [];
while (n) {
result.push(n);
n = this.next();
}
this.init();
return result;
}

private init() {
this.generator = this.createGen();
}

private *createGen(): Generator<TVector, undefined, TVector> {
for (let i = 0; i < this.numSimulations; i += 1) {
yield [...Array(this.numAttacks)].map(() => D6.roll());
}
return undefined;
}
}

export default SimulationGenerator;

0 comments on commit 9452622

Please sign in to comment.