1- import type { RangeTable , Curse , Mutation , MutationEntry , TargetCurseEntry , MixCurseEntry } from './types' ;
2- import { TRACK_TYPES , MUTATIONS , TARGET_CURSES , MIX_CURSES } from './data' ;
1+ import type { RangeTable , Curse , Mutation , MutationEntry , TargetCurseEntry , MixCurseEntry , CurseTargetMethod } from './types' ;
2+ import { TRACK_TYPES , MUTATIONS , TARGET_CURSES , MIX_CURSES , CURSE_TARGETS_FIRST , CURSE_TARGETS_SECOND } from './data' ;
33import { getState , updateState , addLogEntry , createTrack } from './state' ;
44
55export function roll ( max = 100 ) : number {
@@ -123,6 +123,86 @@ export function rollCurseCheck(): void {
123123 }
124124}
125125
126+ function getAvailableTrackIndices ( ) : number [ ] {
127+ const state = getState ( ) ;
128+ if ( ! state ) return [ ] ;
129+
130+ return state . tracks
131+ . map ( ( track , idx ) => ( { track, idx } ) )
132+ . filter ( ( { track, idx } ) => ! track . deleted && idx !== state . roomLockTrack )
133+ . map ( ( { idx } ) => idx ) ;
134+ }
135+
136+ function getCurseTargetMethod ( rollValue : number ) : CurseTargetMethod {
137+ const result = getFromTable ( rollValue , CURSE_TARGETS_FIRST ) ;
138+ switch ( result ) {
139+ case 'Previous Track' : return 'previous' ;
140+ case 'Oldest Track' : return 'oldest' ;
141+ case 'Loudest Track' : return 'loudest' ;
142+ case 'Quietest Track' : return 'quietest' ;
143+ case "Player's Choice" : return 'player-choice' ;
144+ case 'Roll again for two Targets' : return 'two-targets' ;
145+ default : return 'previous' ;
146+ }
147+ }
148+
149+ function getSecondRollOffset ( rollValue : number ) : - 1 | 0 | 1 {
150+ const result = getFromTable ( rollValue , CURSE_TARGETS_SECOND ) ;
151+ switch ( result ) {
152+ case 'Track Before' : return - 1 ;
153+ case 'That Track' : return 0 ;
154+ case 'Track After' : return 1 ;
155+ default : return 0 ;
156+ }
157+ }
158+
159+ function applyOffsetToTarget ( anchorIndex : number , offset : - 1 | 0 | 1 ) : number {
160+ const state = getState ( ) ;
161+ if ( ! state ) return anchorIndex ;
162+
163+ const available = getAvailableTrackIndices ( ) ;
164+ if ( available . length === 0 ) return anchorIndex ;
165+
166+ const targetIndex = anchorIndex + offset ;
167+
168+ if ( targetIndex < 0 || targetIndex >= state . tracks . length ) {
169+ return anchorIndex ;
170+ }
171+
172+ if ( state . tracks [ targetIndex ] . deleted || targetIndex === state . roomLockTrack ) {
173+ return anchorIndex ;
174+ }
175+
176+ return targetIndex ;
177+ }
178+
179+ function resolveAutomaticTarget ( method : CurseTargetMethod ) : number | null {
180+ const state = getState ( ) ;
181+ if ( ! state ) return null ;
182+
183+ const available = getAvailableTrackIndices ( ) ;
184+ if ( available . length === 0 ) return null ;
185+
186+ let anchorIndex : number ;
187+ switch ( method ) {
188+ case 'previous' :
189+ anchorIndex = available [ available . length - 1 ] ;
190+ break ;
191+ case 'oldest' :
192+ anchorIndex = available [ 0 ] ;
193+ break ;
194+ default :
195+ return null ;
196+ }
197+
198+ const secondRoll = roll ( ) ;
199+ const offset = getSecondRollOffset ( secondRoll ) ;
200+ const offsetLabel = offset === - 1 ? 'Track Before' : offset === 1 ? 'Track After' : 'That Track' ;
201+ addLogEntry ( `Second Roll: ${ secondRoll } → ${ offsetLabel } ` ) ;
202+
203+ return applyOffsetToTarget ( anchorIndex , offset ) ;
204+ }
205+
126206function rollTargetCurse ( ) : void {
127207 const state = getState ( ) ;
128208 if ( ! state ) return ;
@@ -132,7 +212,111 @@ function rollTargetCurse(): void {
132212
133213 const curse : Curse = { type : 'Target Curse' , roll : curseRoll , effect : entry . text } ;
134214 addLogEntry ( `Target Curse Roll: ${ curseRoll } → ${ entry . text } ` ) ;
135- updateState ( { currentCurse : curse , phase : 'curse-result' } ) ;
215+
216+ const available = getAvailableTrackIndices ( ) ;
217+
218+ if ( available . length === 0 ) {
219+ addLogEntry ( 'No available tracks to curse' ) ;
220+ updateState ( { currentCurse : curse , phase : 'curse-result' , pendingCurseTargets : [ ] } ) ;
221+ return ;
222+ }
223+
224+ if ( state . curseTargetTrackIndex !== null ) {
225+ const targetIdx = state . curseTargetTrackIndex ;
226+ if ( ! state . tracks [ targetIdx ] ?. deleted && targetIdx !== state . roomLockTrack ) {
227+ addLogEntry ( `Curse Target: Track ${ targetIdx + 1 } (permanent target)` ) ;
228+ updateState ( { currentCurse : curse , phase : 'curse-result' , pendingCurseTargets : [ targetIdx ] } ) ;
229+ return ;
230+ }
231+ }
232+
233+ const targetRoll = roll ( ) ;
234+ const method = getCurseTargetMethod ( targetRoll ) ;
235+ addLogEntry ( `Curse Target Roll: ${ targetRoll } → ${ method } ` ) ;
236+
237+ if ( method === 'two-targets' ) {
238+ addLogEntry ( 'Rolling for two targets' ) ;
239+ const targets : number [ ] = [ ] ;
240+
241+ for ( let i = 0 ; i < 2 ; i ++ ) {
242+ const subRoll = roll ( ) ;
243+ const subMethod = getCurseTargetMethod ( subRoll ) ;
244+ addLogEntry ( `Target ${ i + 1 } Roll: ${ subRoll } → ${ subMethod } ` ) ;
245+
246+ if ( subMethod === 'two-targets' ) {
247+ addLogEntry ( 'Nested two-targets, defaulting to previous' ) ;
248+ const fallbackTarget = resolveAutomaticTarget ( 'previous' ) ;
249+ if ( fallbackTarget !== null ) {
250+ targets . push ( fallbackTarget ) ;
251+ }
252+ } else if ( subMethod === 'loudest' || subMethod === 'quietest' || subMethod === 'player-choice' ) {
253+ updateState ( {
254+ currentCurse : curse ,
255+ phase : 'curse-target-select' ,
256+ curseTargetMethod : subMethod ,
257+ curseTargetRoll : targetRoll ,
258+ pendingCurseTargets : targets ,
259+ } ) ;
260+ return ;
261+ } else {
262+ const autoTarget = resolveAutomaticTarget ( subMethod ) ;
263+ if ( autoTarget !== null ) {
264+ targets . push ( autoTarget ) ;
265+ addLogEntry ( `Target ${ i + 1 } : Track ${ autoTarget + 1 } ` ) ;
266+ }
267+ }
268+ }
269+
270+ updateState ( { currentCurse : curse , phase : 'curse-result' , pendingCurseTargets : [ ...new Set ( targets ) ] } ) ;
271+ return ;
272+ }
273+
274+ if ( method === 'loudest' || method === 'quietest' || method === 'player-choice' ) {
275+ updateState ( {
276+ currentCurse : curse ,
277+ phase : 'curse-target-select' ,
278+ curseTargetMethod : method ,
279+ curseTargetRoll : targetRoll ,
280+ pendingCurseTargets : [ ] ,
281+ } ) ;
282+ return ;
283+ }
284+
285+ const autoTarget = resolveAutomaticTarget ( method ) ;
286+ if ( autoTarget !== null ) {
287+ addLogEntry ( `Curse Target: Track ${ autoTarget + 1 } ` ) ;
288+ }
289+
290+ updateState ( {
291+ currentCurse : curse ,
292+ phase : 'curse-result' ,
293+ pendingCurseTargets : autoTarget !== null ? [ autoTarget ] : [ ] ,
294+ } ) ;
295+ }
296+
297+ export function selectCurseTarget ( trackIndex : number ) : void {
298+ const state = getState ( ) ;
299+ if ( ! state ?. currentCurse ) return ;
300+
301+ let finalTarget = trackIndex ;
302+
303+ if ( state . curseTargetMethod === 'loudest' || state . curseTargetMethod === 'quietest' ) {
304+ const secondRoll = roll ( ) ;
305+ const offset = getSecondRollOffset ( secondRoll ) ;
306+ const offsetLabel = offset === - 1 ? 'Track Before' : offset === 1 ? 'Track After' : 'That Track' ;
307+ addLogEntry ( `Second Roll: ${ secondRoll } → ${ offsetLabel } ` ) ;
308+ finalTarget = applyOffsetToTarget ( trackIndex , offset ) ;
309+ }
310+
311+ addLogEntry ( `Curse Target: Track ${ finalTarget + 1 } ` ) ;
312+ const targets = [ ...state . pendingCurseTargets , finalTarget ] ;
313+
314+ updateState ( {
315+ phase : 'curse-result' ,
316+ pendingCurseTargets : [ ...new Set ( targets ) ] ,
317+ curseTargetMethod : null ,
318+ curseTargetRoll : null ,
319+ } ) ;
136320}
137321
138322function rollMixCurse ( ) : void {
@@ -176,18 +360,20 @@ export function acceptCurse(): void {
176360 if ( state . currentCurse . type === 'Target Curse' ) {
177361 const targetEntry = curseEntry as TargetCurseEntry ;
178362
179- let targetIdx = curseTargetTrackIndex !== null
180- ? curseTargetTrackIndex
181- : ( tracks . length > 0 ? tracks . length - 1 : - 1 ) ;
182-
183- if ( state . roomLockTrack !== null && targetIdx === state . roomLockTrack ) {
184- addLogEntry ( 'Room Lock prevented curse on protected track' ) ;
185- } else if ( targetIdx >= 0 ) {
186- tracks [ targetIdx ] = {
187- ...tracks [ targetIdx ] ,
188- curses : [ ...tracks [ targetIdx ] . curses , state . currentCurse . effect ] ,
189- deleted : targetEntry . mechanics ?. deleteTrack ? true : tracks [ targetIdx ] . deleted ,
190- } ;
363+ for ( const targetIdx of state . pendingCurseTargets ) {
364+ if ( targetIdx >= 0 && targetIdx < tracks . length ) {
365+ tracks [ targetIdx ] = {
366+ ...tracks [ targetIdx ] ,
367+ curses : [ ...tracks [ targetIdx ] . curses , state . currentCurse . effect ] ,
368+ deleted : targetEntry . mechanics ?. deleteTrack ? true : tracks [ targetIdx ] . deleted ,
369+ } ;
370+ addLogEntry ( `Curse applied to Track ${ targetIdx + 1 } ` ) ;
371+
372+ if ( targetEntry . mechanics ?. becomesCurseTarget ) {
373+ curseTargetTrackIndex = targetIdx ;
374+ addLogEntry ( `Track ${ targetIdx + 1 } is now the target of all future curses` ) ;
375+ }
376+ }
191377 }
192378
193379 if ( currentTrack ) {
@@ -213,11 +399,6 @@ export function acceptCurse(): void {
213399 doubleMutationNextRoom = true ;
214400 addLogEntry ( 'Next room will have two mutations' ) ;
215401 }
216-
217- if ( targetEntry . mechanics ?. becomesCurseTarget && targetIdx >= 0 ) {
218- curseTargetTrackIndex = targetIdx ;
219- addLogEntry ( `Track ${ targetIdx + 1 } is now the target of all future curses` ) ;
220- }
221402 } else {
222403 const mixEntry = curseEntry as MixCurseEntry ;
223404 if ( mixEntry . mechanics ?. rollTargetCurses ) {
@@ -233,6 +414,7 @@ export function acceptCurse(): void {
233414 doubleMutationNextRoom,
234415 curseTargetTrackIndex,
235416 currentCurse : null ,
417+ pendingCurseTargets : [ ] ,
236418 phase : 'mutation' ,
237419 } ) ;
238420}
@@ -433,6 +615,9 @@ export function nextRoom(): void {
433615 currentCurse : null ,
434616 timerEndTime : null ,
435617 pendingTrackTypeReselect : false ,
618+ pendingCurseTargets : [ ] ,
619+ curseTargetMethod : null ,
620+ curseTargetRoll : null ,
436621 } ) ;
437622}
438623
0 commit comments