From c885d2d40586cf94a1ae2e9c3d0e106ca6fe0015 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Oliver=20R=C3=BCmpelein?= <oli_r@fg4f.de>
Date: Wed, 6 Jan 2021 19:15:56 +0100
Subject: [PATCH] Implement a custom DS4 "dice" class.

Includes exploding and crit dice modifier, but not tested yet,
as some required types are still missing.
---
 src/module/ds4.ts                 |  9 +++
 src/module/rolls/ds4roll.ts       | 95 +++++++++++++++++++++++++++++++
 src/module/rolls/roll-data.ts     |  1 +
 src/module/rolls/roll-executor.ts | 10 ++--
 src/module/rolls/roll-provider.ts |  3 +-
 5 files changed, 112 insertions(+), 6 deletions(-)
 create mode 100644 src/module/rolls/ds4roll.ts

diff --git a/src/module/ds4.ts b/src/module/ds4.ts
index 358074b0..ddc135ae 100644
--- a/src/module/ds4.ts
+++ b/src/module/ds4.ts
@@ -4,6 +4,7 @@ import { DS4ActorSheet } from "./actor/actor-sheet";
 import { DS4Item } from "./item/item";
 import { DS4ItemSheet } from "./item/item-sheet";
 import { DS4 } from "./config";
+import { DS4Roll } from "./rolls/ds4roll";
 
 Hooks.once("init", async function () {
     console.log(`DS4 | Initializing the DS4 Game System\n${DS4.ASCII}`);
@@ -21,6 +22,14 @@ Hooks.once("init", async function () {
     CONFIG.Actor.entityClass = DS4Actor as typeof Actor;
     CONFIG.Item.entityClass = DS4Item as typeof Item;
 
+    // Configure Dice
+    CONFIG.Dice.types = [Die, DS4Roll];
+    CONFIG.Dice.terms = {
+        c: Coin,
+        d: Die,
+        s: DS4Roll,
+    };
+
     // Register sheet application classes
     Actors.unregisterSheet("core", ActorSheet);
     Actors.registerSheet("ds4", DS4ActorSheet, { makeDefault: true });
diff --git a/src/module/rolls/ds4roll.ts b/src/module/rolls/ds4roll.ts
new file mode 100644
index 00000000..336fb354
--- /dev/null
+++ b/src/module/rolls/ds4roll.ts
@@ -0,0 +1,95 @@
+import { RollResult, RollResultStatus } from "./roll-data";
+import { ds4roll } from "./roll-executor";
+
+export class DS4Roll extends DiceTerm {
+    constructor({ faces = 20, modifiers = [], options = {} } = {}) {
+        super({ number: 1, faces: faces, modifiers: modifiers, options: options });
+    }
+
+    /**
+     * @override
+     * @param param0
+     */
+    roll({ minimize = false, maximize = false } = {}): RollResult {
+        return this.rollWithDifferentBorders(1, 20, { minimize, maximize });
+    }
+
+    rollWithDifferentBorders(
+        maxCritSuccess: number,
+        minCritFail: number,
+        { minimize = false, maximize = false } = {},
+    ): RollResult {
+        if (minimize) {
+            return new RollResult(0, RollResultStatus.CRITICAL_FAILURE, [20], true);
+        } else if (maximize) {
+            return new RollResult(
+                this.faces,
+                RollResultStatus.CRITICAL_SUCCESS,
+                Array(Math.ceil(this.faces / 20)).fill(1),
+                true,
+            );
+        } else {
+            return ds4roll(this.faces, { maxCritSucc: maxCritSuccess, minCritFail: minCritFail });
+        }
+    }
+
+    /**
+     * @override
+     */
+    get values(): Array<number> {
+        return (this.results as Array<RollResult>)
+            .filter((r) => r.active)
+            .map((r) => r.dice)
+            .reduce((acc: Array<number>, cur: Array<number>) => {
+                return acc.concat(cur);
+            }, []);
+    }
+
+    /** Term Modifiers */
+    crits(modifier: string): this {
+        const rgx: RegExp = /[c([0-9]+)?,([0-9]+)?]/;
+        const match = modifier.match(rgx);
+        if (!match) return this;
+        const [parseCritSucc, parsedCritFail] = match.slice(1);
+
+        const maxCritSuccess = parseCritSucc ? parseInt(parseCritSucc) : 1;
+        const minCritFail = parsedCritFail ? parseInt(parsedCritFail) : 20;
+
+        const newResults: Array<RollResult> = (this.results as Array<RollResult>).map((r) => {
+            return ds4roll(this.faces, { minCritFail: minCritFail, maxCritSucc: maxCritSuccess }, r.dice);
+        });
+
+        this.results = newResults;
+    }
+
+    // DS4 only allows recursive explosions
+    explode(modifier: string): this {
+        // There should only ever be a single dice in the results-array at this point!
+        if (this.results.length != 1) {
+            // TODO: Add 'expression' to types!
+            // console.error(`Skipped explode for term ${this.expression}`);
+            return this;
+        }
+
+        const rgx = /[xX]([0-9]+)?/;
+        const match = modifier.match(rgx);
+        if (!match) return this;
+        const [parsedCritSucc] = match.slice(1);
+
+        const maxCritSucc = parsedCritSucc ? parseInt(parsedCritSucc) : 1;
+
+        let checked = 0;
+        while (checked < this.results.length) {
+            const r = (this.results as Array<RollResult>)[checked];
+            checked++;
+            if (!r.active) continue;
+
+            if (r.dice[0] <= maxCritSucc) {
+                r.exploded = true;
+                this.rollWithDifferentBorders(maxCritSucc, 21);
+            }
+
+            if (checked > 1000) throw new Error("Maximum recursion depth for explodign dice roll exceeded");
+        }
+    }
+}
diff --git a/src/module/rolls/roll-data.ts b/src/module/rolls/roll-data.ts
index f5b2b13e..516f8c61 100644
--- a/src/module/rolls/roll-data.ts
+++ b/src/module/rolls/roll-data.ts
@@ -22,6 +22,7 @@ export class RollResult {
         public status: RollResultStatus,
         public dice: Array<number>,
         public active: boolean = true,
+        public exploded: boolean = false,
     ) {}
 }
 
diff --git a/src/module/rolls/roll-executor.ts b/src/module/rolls/roll-executor.ts
index 66d07690..b28ca4a3 100644
--- a/src/module/rolls/roll-executor.ts
+++ b/src/module/rolls/roll-executor.ts
@@ -48,15 +48,15 @@ export function rollCheckSingleDie(
         dice = [new DS4RollProvider().getNextRoll()];
     }
     const usedDice = dice;
-    const roll = usedDice[0];
+    const rolledDie = usedDice[0];
 
-    if (roll <= usedOptions.maxCritSucc) {
+    if (rolledDie <= usedOptions.maxCritSucc) {
         return new RollResult(checkTargetValue, RollResultStatus.CRITICAL_SUCCESS, usedDice, true);
-    } else if (roll >= usedOptions.minCritFail && !isSlayingDiceRepetition(usedOptions)) {
+    } else if (rolledDie >= usedOptions.minCritFail && !isSlayingDiceRepetition(usedOptions)) {
         return new RollResult(0, RollResultStatus.CRITICAL_FAILURE, usedDice, true);
     } else {
-        if (roll <= checkTargetValue) {
-            return new RollResult(roll, RollResultStatus.SUCCESS, usedDice, true);
+        if (rolledDie <= checkTargetValue) {
+            return new RollResult(rolledDie, RollResultStatus.SUCCESS, usedDice, true);
         } else {
             return new RollResult(0, RollResultStatus.FAILURE, usedDice, true);
         }
diff --git a/src/module/rolls/roll-provider.ts b/src/module/rolls/roll-provider.ts
index 0781e5b0..86c55606 100644
--- a/src/module/rolls/roll-provider.ts
+++ b/src/module/rolls/roll-provider.ts
@@ -6,7 +6,8 @@
  */
 export class DS4RollProvider implements RollProvider {
     getNextRoll(): number {
-        return new Roll("1d20").roll().total;
+        const rand = CONFIG.Dice.randomUniform();
+        return Math.ceil(rand * 20);
     }
 
     getNextRolls(amount: number): Array<number> {