Skip to content

Commit 11d6305

Browse files
committed
Coordinate event timing to prevent same-day phase collisions
checkForEvent returns the first matching event per phase, so two events at the same (day, phase) meant one was silently dropped. 76% of seeds had collisions; 218/1000 had two obligation notifications on the same day, breaking the entire obligation arc. Obligation notifications now schedule most-constrained-first and track used days. A coordination pass then reassigns standalone events away from occupied (day, phase) slots. dayStart collisions go from 366 to 0 across 1000 seeds.
1 parent d4e8fc6 commit 11d6305

2 files changed

Lines changed: 267 additions & 14 deletions

File tree

src/systems/eventSelection.test.ts

Lines changed: 152 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,11 @@
11
import { describe, expect, it } from "bun:test";
22
import { eventPool, getEventDefinition } from "../data/events";
3-
import { getProgressionTier, selectEventsForSeed } from "./eventSelection";
3+
import type { EventInstance } from "../state";
4+
import {
5+
coordinateEventTiming,
6+
getProgressionTier,
7+
selectEventsForSeed,
8+
} from "./eventSelection";
49
import type { CompletedRun, PatternsData } from "./persistence";
510

611
/** Creates a minimal PatternsData with a given number of main-mode completions. */
@@ -304,4 +309,150 @@ describe("assignObligationDays", () => {
304309
}
305310
}
306311
});
312+
313+
it("obligation notifications land on different days", () => {
314+
for (let seed = 0; seed < 200; seed++) {
315+
const events = selectEventsForSeed(seed, patterns);
316+
const notificationDays: number[] = [];
317+
for (const event of events) {
318+
const def = getEventDefinition(event.id);
319+
if (def?.obligation && event.scheduledDay !== undefined) {
320+
notificationDays.push(event.scheduledDay);
321+
}
322+
}
323+
// All notification days should be unique
324+
const uniqueDays = new Set(notificationDays);
325+
expect(uniqueDays.size).toBe(notificationDays.length);
326+
}
327+
});
328+
});
329+
330+
describe("coordinateEventTiming", () => {
331+
it("moves standalone dayStart away from obligation dayStart", () => {
332+
const events: EventInstance[] = [
333+
{
334+
id: "dentist-reminder",
335+
status: "pending",
336+
scheduledDay: 0,
337+
obligationDay: 2,
338+
},
339+
{ id: "dentist-missed", status: "pending", scheduledDay: 2 },
340+
// cold-apartment: standalone, dayStart, allowed Mon-Wed (0-2)
341+
{ id: "cold-apartment", status: "pending", scheduledDay: 0 },
342+
];
343+
344+
coordinateEventTiming(events, 42);
345+
346+
const coldApt = events.find((e) => e.id === "cold-apartment");
347+
expect(coldApt).toBeDefined();
348+
expect(coldApt?.scheduledDay).not.toBe(0);
349+
// Should be on one of its other allowed days (1 or 2)
350+
expect([1, 2]).toContain(coldApt?.scheduledDay ?? -1);
351+
});
352+
353+
it("spreads two standalone dayStart events to different days", () => {
354+
const events: EventInstance[] = [
355+
// Both dayStart, both on Tuesday
356+
{ id: "cold-apartment", status: "pending", scheduledDay: 1 },
357+
{ id: "hot-water-out", status: "pending", scheduledDay: 1 },
358+
];
359+
360+
coordinateEventTiming(events, 42);
361+
362+
const coldApt = events.find((e) => e.id === "cold-apartment");
363+
const hotWater = events.find((e) => e.id === "hot-water-out");
364+
expect(coldApt?.scheduledDay).not.toBe(hotWater?.scheduledDay);
365+
});
366+
367+
it("does not move events when no conflict exists", () => {
368+
const events: EventInstance[] = [
369+
{ id: "cold-apartment", status: "pending", scheduledDay: 0 },
370+
{ id: "hot-water-out", status: "pending", scheduledDay: 2 },
371+
];
372+
373+
coordinateEventTiming(events, 42);
374+
375+
expect(events.find((e) => e.id === "cold-apartment")?.scheduledDay).toBe(0);
376+
expect(events.find((e) => e.id === "hot-water-out")?.scheduledDay).toBe(2);
377+
});
378+
379+
it("obligation events take priority over standalone events", () => {
380+
const events: EventInstance[] = [
381+
{
382+
id: "dentist-reminder",
383+
status: "pending",
384+
scheduledDay: 1,
385+
obligationDay: 3,
386+
},
387+
{ id: "dentist-missed", status: "pending", scheduledDay: 3 },
388+
{ id: "cold-apartment", status: "pending", scheduledDay: 1 },
389+
];
390+
391+
coordinateEventTiming(events, 42);
392+
393+
// Obligation stays in place
394+
expect(events.find((e) => e.id === "dentist-reminder")?.scheduledDay).toBe(
395+
1,
396+
);
397+
// Standalone moved
398+
expect(
399+
events.find((e) => e.id === "cold-apartment")?.scheduledDay,
400+
).not.toBe(1);
401+
});
402+
403+
it("does not coordinate across different phases", () => {
404+
// dayStart and blockStart on same day is fine (different phase checks)
405+
const events: EventInstance[] = [
406+
{ id: "cold-apartment", status: "pending", scheduledDay: 1 }, // dayStart
407+
{ id: "rain", status: "pending", scheduledDay: 1 }, // blockStart
408+
];
409+
410+
coordinateEventTiming(events, 42);
411+
412+
// Both should keep their day -- different phases don't conflict
413+
expect(events.find((e) => e.id === "cold-apartment")?.scheduledDay).toBe(1);
414+
expect(events.find((e) => e.id === "rain")?.scheduledDay).toBe(1);
415+
});
416+
417+
it("produces deterministic results", () => {
418+
const makeEvents = (): EventInstance[] => [
419+
{
420+
id: "dentist-reminder",
421+
status: "pending",
422+
scheduledDay: 0,
423+
obligationDay: 2,
424+
},
425+
{ id: "dentist-missed", status: "pending", scheduledDay: 2 },
426+
{ id: "cold-apartment", status: "pending", scheduledDay: 0 },
427+
];
428+
429+
const a = makeEvents();
430+
const b = makeEvents();
431+
coordinateEventTiming(a, 42);
432+
coordinateEventTiming(b, 42);
433+
434+
expect(a).toEqual(b);
435+
});
436+
437+
it("no dayStart collisions across 200 seeds at tier 3", () => {
438+
const patterns = patternsWithCompletions(4);
439+
for (let seed = 0; seed < 200; seed++) {
440+
const events = selectEventsForSeed(seed, patterns);
441+
const dayStartDays = new Map<number, string[]>();
442+
443+
for (const event of events) {
444+
if (event.scheduledDay === undefined) continue;
445+
const def = getEventDefinition(event.id);
446+
if (!def || def.timing.phase !== "dayStart") continue;
447+
448+
const existing = dayStartDays.get(event.scheduledDay) ?? [];
449+
existing.push(event.id);
450+
dayStartDays.set(event.scheduledDay, existing);
451+
}
452+
453+
for (const [, ids] of dayStartDays) {
454+
expect(ids.length).toBe(1);
455+
}
456+
}
457+
});
307458
});

src/systems/eventSelection.ts

Lines changed: 115 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66

77
import {
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
*/
193200
function 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

Comments
 (0)