Skip to content

Commit 7c4dbe8

Browse files
committed
ADD - kh & kl dice formula modifiers
1 parent 03b6d67 commit 7c4dbe8

2 files changed

Lines changed: 239 additions & 27 deletions

File tree

src/module/rolls/ABFExploderRoll/ABFExploderRoll.js

Lines changed: 229 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -3,65 +3,268 @@ import ABFFoundryRoll from '../ABFFoundryRoll';
33
import { ABFRoll } from '../ABFRoll';
44

55
export default class ABFExploderRoll extends ABFRoll {
6-
// Expose same API as before
6+
// Public API preserved
77
get fumbled() {
88
return this.foundryRoll.firstResult <= this.fumbleRange;
99
}
1010

1111
/** @returns {Promise<ABFFoundryRoll>} */
1212
async evaluate() {
13-
// Safety: ensure there is at least one result
1413
if (!this.firstDice || !this.firstDice.results?.length) return this.foundryRoll;
1514

16-
// Fumble only on the first base die (same behavior as before)
17-
this.firstDice.results[0].failure =
18-
this.firstDice.results[0].result <= this.fumbleRange;
15+
// Reentrancy guard: avoid exploding twice if evaluate() is called again
16+
if (this.firstDice.__abfExploded) {
17+
const baseCount = this.firstDice.__baseCount ?? this._getBaseCount();
18+
const keepCfg = this._parseKeepFromFormula();
19+
if (keepCfg) this._applyKeepOverGroups(keepCfg, baseCount);
20+
this._markFumblesBaseOnly(); // UI: fumbles only on base dice
21+
this.foundryRoll.recalculateTotal();
22+
return this.foundryRoll;
23+
}
24+
this.firstDice.__abfExploded = true;
25+
26+
// Determine base dice count and pre-group engine explosions (from 'x')
27+
const baseCount = this._getBaseCount();
28+
this.firstDice.__baseCount = baseCount;
29+
this._preGroupExistingExplosions(baseCount);
1930

20-
// Snapshot of base results count (e.g., 2 for "2d100")
21-
const baseCount = this.firstDice.results.length;
31+
// Keep original index for stable ordering
32+
this.firstDice.results.forEach((r, i) => (r.__origIndex = i));
2233

23-
// Process each base result independently
34+
// Open-chain per base group (threshold increases +1 per explosion)
2435
for (let i = 0; i < baseCount; i++) {
2536
await this._explodeChainFromIndex(i);
2637
}
2738

28-
// Sum again after appending extra dice
39+
// Order for display: group → chain → original index
40+
this._orderResultsForDisplay();
41+
42+
// Clean flags and apply keep-high/keep-low over grouped chains
43+
for (const r of this.firstDice.results) {
44+
r.discarded = false;
45+
r.active = true;
46+
if (r.count === 0) r.count = 1;
47+
}
48+
const keepCfg = this._parseKeepFromFormula();
49+
if (keepCfg) this._applyKeepOverGroups(keepCfg, baseCount);
50+
51+
// UI: only base dice (chainIndex 0) can be fumbles; exploded extras never
52+
this._markFumblesBaseOnly();
53+
2954
this.foundryRoll.recalculateTotal();
3055
return this.foundryRoll;
3156
}
3257

3358
/**
34-
* Start an open-roll chain from the base result at `index`.
35-
* Uses openRollRange and increases it by +1 per explosion (capped at 100).
59+
* Continue an open-roll chain starting from the last result of group `index`.
60+
* Starts threshold at openRollRange + existingExplosions (capped at 100).
3661
*/
3762
async _explodeChainFromIndex(index) {
38-
let threshold = this.openRollRange;
39-
let currentObj = this.firstDice.results[index];
40-
41-
while (
42-
currentObj.result >= threshold ||
43-
(this.openOnDoubles && (await this._checkDoubles(currentObj.result)))
44-
) {
45-
// Mark the triggering result for UI
63+
if (!this.firstDice) return;
64+
65+
let currentObj = this._lastInGroup(index);
66+
let threshold = Math.min(this.openRollRange + (this._groupSize(index) - 1), 100);
67+
68+
while (true) {
69+
// Doubles: if matched, count the triggering result as 100
70+
const explodedByDoubles = this.openOnDoubles
71+
? await this._applyDoublesRule(currentObj)
72+
: false;
73+
74+
// Threshold rule (>= threshold)
75+
const meetsThreshold = currentObj.result >= threshold;
76+
77+
if (!(meetsThreshold || explodedByDoubles)) break;
78+
79+
// Mark for UI
4680
currentObj.success = true;
4781
currentObj.exploded = true;
4882

49-
// Roll extra 1d100 and append to the same Die (same chain)
83+
// Roll extra 1d100 and append to the same group/chain
5084
const extra = new ABFFoundryRoll('1d100');
5185
await extra.evaluate();
5286
this.addRoll(extra);
5387

54-
// Next link in the chain: raise threshold and point to the new last result
88+
const last = this.firstDice.results[this.firstDice.results.length - 1];
89+
last.__group = index;
90+
last.__chainIndex = (currentObj.__chainIndex ?? 0) + 1; // next in chain
91+
last.__origIndex = this.firstDice.results.length - 1; // fallback stable
92+
5593
threshold = Math.min(threshold + 1, 100);
56-
currentObj = this.firstDice.results[this.firstDice.results.length - 1];
94+
currentObj = last;
5795
}
5896
}
5997

60-
/** Optional "open on doubles" rule: roll 1d10 and compare to the repeated digit */
61-
async _checkDoubles(value) {
62-
if (value % 11 !== 0) return false;
98+
/**
99+
* If doubles rule applies (value is 11,22,...,99 and d10 matches), treat as 100.
100+
*/
101+
async _applyDoublesRule(obj) {
102+
const v = obj.result;
103+
if (v % 11 !== 0) return false;
104+
63105
const d10 = new ABFFoundryRoll('1d10');
64106
await d10.evaluate();
65-
return d10.total === value / 11;
107+
108+
const ok = d10.total === v / 11;
109+
if (ok) {
110+
obj.__originalResult = v; // for UI/debug
111+
obj.__doublesAs100 = true; // marker
112+
obj.result = 100; // counts as 100
113+
}
114+
return ok;
115+
}
116+
117+
/**
118+
* Parse kh/kl (optionally numbered) from the user formula, then normalized, then dice modifiers.
119+
* Supports xakh, xakl2, kh3, kl, etc.
120+
*/
121+
_parseKeepFromFormula() {
122+
const scan = s => {
123+
if (!s) return null;
124+
const f = String(s).replace(/\s+/g, '').toLowerCase();
125+
let m = f.match(/xa?k([hl])(\d+)?/); // xakh / xaklN
126+
if (!m) m = f.match(/k([hl])(\d+)?/); // khN / klN
127+
if (!m) return null;
128+
const mode = m[1]; // 'h'|'l'
129+
const count = m[2] ? Math.max(1, parseInt(m[2], 10)) : 1;
130+
return { mode, count };
131+
};
132+
133+
// Prefer original user formula
134+
let res = scan(this.foundryRoll?._formula);
135+
if (res) return res;
136+
137+
// Then normalized formula
138+
res = scan(this.foundryRoll?.formula);
139+
if (res) return res;
140+
141+
// Finally, dice term modifiers
142+
const mods = (this.firstDice?.modifiers ?? []).join('');
143+
return scan(mods);
144+
}
145+
146+
/**
147+
* Apply keep-high / keep-low over group sums.
148+
* Non-kept groups are visually kept but do not contribute to total.
149+
*/
150+
_applyKeepOverGroups(keepCfg, baseCount) {
151+
const { mode, count } = keepCfg;
152+
const groups = new Map(); // groupId -> sum
153+
154+
// Sum per group (firstDice only)
155+
for (const r of this.firstDice.results) {
156+
const g = r.__group;
157+
if (g === undefined) continue;
158+
groups.set(g, (groups.get(g) ?? 0) + (r.result ?? 0));
159+
}
160+
161+
// Decide which groups to keep
162+
const entries = Array.from(groups.entries()); // [groupId, sum]
163+
entries.sort((a, b) => (mode === 'h' ? b[1] - a[1] : a[1] - b[1]));
164+
const keepSet = new Set(entries.slice(0, Math.min(count, baseCount)).map(e => e[0]));
165+
166+
// Mark non-kept groups as discarded/inactive (do not change result values)
167+
for (const r of this.firstDice.results) {
168+
const g = r.__group;
169+
if (g === undefined) continue;
170+
if (!keepSet.has(g)) {
171+
r.discarded = true;
172+
r.active = false;
173+
r.count = 0; // optional guard if any summation uses 'count'
174+
}
175+
}
176+
}
177+
178+
// --------------------- fumble & ordering helpers ---------------------
179+
180+
/** UI: only base dice (chainIndex 0) can be fumbles; exploded extras never. */
181+
_markFumblesBaseOnly() {
182+
for (const r of this.firstDice.results) {
183+
const isBase = (r.__chainIndex ?? 0) === 0; // base die of its group
184+
const v = typeof r.result === 'number' ? r.result : null;
185+
186+
if (isBase && v !== null && v <= this.fumbleRange) {
187+
r.failure = true; // paint red like a natural 1
188+
r.success = false; // ensure not success
189+
r.isMin = true; // optional UI hint
190+
} else {
191+
if (r.failure) r.failure = false;
192+
if (r.isMin) r.isMin = false;
193+
}
194+
}
195+
}
196+
197+
/** Sort results: group → chain → original index */
198+
_orderResultsForDisplay() {
199+
const arr = this.firstDice.results.slice(); // copy
200+
arr.sort((a, b) => {
201+
const ga = a.__group ?? 0,
202+
gb = b.__group ?? 0;
203+
if (ga !== gb) return ga - gb;
204+
const ca = a.__chainIndex ?? 0,
205+
cb = b.__chainIndex ?? 0;
206+
if (ca !== cb) return ca - cb;
207+
const oa = a.__origIndex ?? 0,
208+
ob = b.__origIndex ?? 0;
209+
return oa - ob;
210+
});
211+
this.firstDice.results = arr;
212+
}
213+
214+
// --------------------- grouping helpers ---------------------
215+
216+
/** Get N from NdX: prefer dice term.number, then parse formula, else fallback. */
217+
_getBaseCount() {
218+
const n = this.firstDice?.number;
219+
if (typeof n === 'number' && n > 0) return n;
220+
const src = this.foundryRoll?._formula || this.foundryRoll?.formula || '';
221+
const m = /(\d+)\s*d\s*\d+/i.exec(src);
222+
return m ? parseInt(m[1], 10) : this.firstDice.results?.length || 1;
223+
}
224+
225+
/**
226+
* Pre-group existing engine explosions created by 'x' (explode on max = 100).
227+
* Assigns a group id and chain index to each base result and to its immediate engine-generated extras.
228+
*/
229+
_preGroupExistingExplosions(baseCount) {
230+
const arr = this.firstDice.results;
231+
232+
// Clear previous tags
233+
for (const r of arr) {
234+
delete r.__group;
235+
delete r.__chainIndex;
236+
}
237+
238+
let i = 0;
239+
for (let g = 0; g < baseCount && i < arr.length; g++) {
240+
// Base
241+
arr[i].__group = g;
242+
arr[i].__chainIndex = 0;
243+
244+
// Attach contiguous engine extras caused by a preceding 100
245+
while (
246+
i + 1 < arr.length &&
247+
arr[i].result === 100 &&
248+
arr[i + 1].__group === undefined
249+
) {
250+
i++;
251+
arr[i].__group = g;
252+
arr[i].__chainIndex = (arr[i - 1].__chainIndex ?? 0) + 1;
253+
// Continues if extra was also 100 (100,100,73...)
254+
}
255+
i++;
256+
}
257+
}
258+
259+
_groupSize(groupId) {
260+
let c = 0;
261+
for (const r of this.firstDice.results) if (r.__group === groupId) c++;
262+
return c;
263+
}
264+
265+
_lastInGroup(groupId) {
266+
let last = null;
267+
for (const r of this.firstDice.results) if (r.__group === groupId) last = r;
268+
return last ?? this.firstDice.results[groupId];
66269
}
67270
}

src/module/rolls/ABFFoundryRoll.js

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -79,7 +79,16 @@ export default class ABFFoundryRoll extends Roll {
7979
}
8080

8181
getResults() {
82-
return this.dice.map(d => d.results.map(res => res.result)).flat();
82+
return this.dice
83+
.map(d =>
84+
d.results.map(res => {
85+
const val = typeof res.result === 'number' ? res.result : 0;
86+
const cnt = res?.count ?? 1;
87+
const contributes = !(res?.discarded || res?.active === false || cnt === 0);
88+
return contributes ? val * cnt : 0;
89+
})
90+
)
91+
.flat();
8392
}
8493

8594
// TODO Evaluate not finished this | Promise<this>

0 commit comments

Comments
 (0)