66
77import {
88 type EventDefinition ,
9+ type EventPhase ,
910 type EventTier ,
1011 eventPool ,
1112} from "../data/events" ;
@@ -176,6 +177,9 @@ export function selectEventsForSeed(
176177 // and consequence day are all computed together.
177178 assignObligationDays ( selected , seed ) ;
178179
180+ // Spread events across days to prevent same-(day, phase) collisions.
181+ coordinateEventTiming ( selected , seed ) ;
182+
179183 return selected ;
180184}
181185
@@ -189,9 +193,30 @@ const SALT_OBLIGATION_DAY = 5300;
189193 * Assigns scheduling for obligation arcs: notification gets a scheduledDay,
190194 * obligation day is computed as notification + seeded offset, and the
191195 * consequence event's scheduledDay is set to the obligation day.
196+ *
197+ * Notifications are spread across different days when possible to avoid
198+ * dayStart collisions (checkForEvent returns only the first match per phase).
192199 */
193200function assignObligationDays ( events : EventInstance [ ] , seed : number ) : void {
201+ // Track days already claimed by obligation notifications (all dayStart)
202+ const usedNotificationDays = new Set < number > ( ) ;
203+
204+ // Collect obligation notification indices, sorted by constraint level.
205+ // Most constrained (fewest allowed days) first, so they get first pick
206+ // and less constrained obligations work around them.
207+ const obligationIndices : number [ ] = [ ] ;
194208 for ( let i = 0 ; i < events . length ; i ++ ) {
209+ const def = eventPool . find ( ( e ) => e . id === events [ i ] ?. id ) ;
210+ if ( def ?. obligation ) obligationIndices . push ( i ) ;
211+ }
212+ obligationIndices . sort ( ( a , b ) => {
213+ const defA = eventPool . find ( ( e ) => e . id === events [ a ] ?. id ) ;
214+ const defB = eventPool . find ( ( e ) => e . id === events [ b ] ?. id ) ;
215+ if ( ! defA || ! defB ) return 0 ;
216+ return getAllowedDays ( defA ) . length - getAllowedDays ( defB ) . length ;
217+ } ) ;
218+
219+ for ( const i of obligationIndices ) {
195220 const instance = events [ i ] ;
196221 if ( ! instance ) continue ;
197222
@@ -200,26 +225,23 @@ function assignObligationDays(events: EventInstance[], seed: number): void {
200225
201226 // Schedule the notification event itself (arc events skip assignScheduledDays)
202227 if ( instance . scheduledDay === undefined ) {
203- let allowedDays : number [ ] ;
204- if ( definition . timing . day ) {
205- const days = Array . isArray ( definition . timing . day )
206- ? definition . timing . day
207- : [ definition . timing . day ] ;
208- allowedDays = days
209- . map ( ( d ) => DAYS . indexOf ( d ) )
210- . filter ( ( idx ) => idx >= 0 ) ;
211- } else {
212- allowedDays = [ 0 , 1 , 2 , 3 , 4 ] ;
213- }
214- if ( allowedDays . length > 0 ) {
228+ const allowedDays = getAllowedDays ( definition ) ;
229+ // Prefer days not already used by another obligation notification
230+ const available = allowedDays . filter ( ( d ) => ! usedNotificationDays . has ( d ) ) ;
231+ const candidates = available . length > 0 ? available : allowedDays ;
232+ if ( candidates . length > 0 ) {
215233 const r = seededRandom ( seed , SALT_SCHEDULE_DAY + i ) ;
216- const dayIdx = allowedDays [ Math . floor ( r * allowedDays . length ) ] ;
234+ const dayIdx = candidates [ Math . floor ( r * candidates . length ) ] ;
217235 if ( dayIdx !== undefined ) {
218236 instance . scheduledDay = dayIdx ;
219237 }
220238 }
221239 }
222240
241+ if ( instance . scheduledDay !== undefined ) {
242+ usedNotificationDays . add ( instance . scheduledDay ) ;
243+ }
244+
223245 if ( instance . scheduledDay === undefined ) continue ;
224246
225247 // Compute obligation day: notification day + seeded offset
@@ -248,6 +270,86 @@ function assignObligationDays(events: EventInstance[], seed: number): void {
248270 }
249271}
250272
273+ /** Salt offset for event timing coordination. */
274+ const SALT_COORDINATION = 5400 ;
275+
276+ /**
277+ * Prevents event collisions at the same (day, phase).
278+ * checkForEvent returns only the first matching event per phase transition,
279+ * so two events at the same day and phase means one is silently dropped.
280+ *
281+ * Obligation arc events have highest priority (most constrained timing).
282+ * Standalone events are reassigned to alternative days when possible.
283+ */
284+ export function coordinateEventTiming (
285+ events : EventInstance [ ] ,
286+ seed : number ,
287+ ) : void {
288+ const occupied = new Set < string > ( ) ;
289+
290+ function slotKey ( day : number , phase : EventPhase ) : string {
291+ return `${ day } :${ phase } ` ;
292+ }
293+
294+ // Identify obligation arcs (arcs containing an event with obligation field)
295+ const obligationArcIds = new Set < string > ( ) ;
296+ for ( const instance of events ) {
297+ const def = eventPool . find ( ( e ) => e . id === instance . id ) ;
298+ if ( def ?. obligation && def . arcId ) {
299+ obligationArcIds . add ( def . arcId ) ;
300+ }
301+ }
302+
303+ // Phase 1: Reserve slots for obligation arc events (highest priority)
304+ for ( const instance of events ) {
305+ if ( instance . scheduledDay === undefined ) continue ;
306+ const def = eventPool . find ( ( e ) => e . id === instance . id ) ;
307+ if ( ! def ?. arcId || ! obligationArcIds . has ( def . arcId ) ) continue ;
308+ occupied . add ( slotKey ( instance . scheduledDay , def . timing . phase ) ) ;
309+ }
310+
311+ // Phase 2: Place standalone events, reassigning on conflict
312+ for ( let i = 0 ; i < events . length ; i ++ ) {
313+ const instance = events [ i ] ;
314+ if ( ! instance || instance . scheduledDay === undefined ) continue ;
315+ const def = eventPool . find ( ( e ) => e . id === instance . id ) ;
316+ if ( ! def || def . arcId ) continue ; // standalone only
317+
318+ const k = slotKey ( instance . scheduledDay , def . timing . phase ) ;
319+ if ( ! occupied . has ( k ) ) {
320+ occupied . add ( k ) ;
321+ continue ;
322+ }
323+
324+ // Conflict: find an alternative day from the event's allowed range
325+ const allowedDays = getAllowedDays ( def ) ;
326+ const alternatives = allowedDays . filter (
327+ ( d ) => ! occupied . has ( slotKey ( d , def . timing . phase ) ) ,
328+ ) ;
329+
330+ if ( alternatives . length > 0 ) {
331+ const r = seededRandom ( seed , SALT_COORDINATION + i ) ;
332+ const newDay = alternatives [ Math . floor ( r * alternatives . length ) ] ;
333+ if ( newDay !== undefined ) {
334+ instance . scheduledDay = newDay ;
335+ }
336+ }
337+ // Mark final position as occupied (whether moved or not)
338+ occupied . add ( slotKey ( instance . scheduledDay , def . timing . phase ) ) ;
339+ }
340+ }
341+
342+ /** Gets the allowed day indices for an event definition. */
343+ function getAllowedDays ( def : EventDefinition ) : number [ ] {
344+ if ( def . timing . day ) {
345+ const days = Array . isArray ( def . timing . day )
346+ ? def . timing . day
347+ : [ def . timing . day ] ;
348+ return days . map ( ( d ) => DAYS . indexOf ( d ) ) . filter ( ( idx ) => idx >= 0 ) ;
349+ }
350+ return [ 0 , 1 , 2 , 3 , 4 ] ;
351+ }
352+
251353/**
252354 * Assigns a scheduledDay to each non-arc standalone event, picking from its
253355 * allowed day range using seeded random. Arc events are skipped (their day
0 commit comments