@@ -3,65 +3,268 @@ import ABFFoundryRoll from '../ABFFoundryRoll';
33import { ABFRoll } from '../ABFRoll' ;
44
55export 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 ( / x a ? k ( [ h l ] ) ( \d + ) ? / ) ; // xakh / xaklN
126+ if ( ! m ) m = f . match ( / k ( [ h l ] ) ( \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}
0 commit comments