when-am-i / commit
feat: fairer shuffle — equal per calendar, skip recents
src/utils/shuffle.test.ts | 36 ++++++++++++++++++++++++++++++++++++ src/utils/shuffle.ts | 43 ++++++++++++++++++++++++++++++++++--------- 2 files changed, 70 insertions(+), 9 deletions(-) diff --git a/src/utils/shuffle.test.ts b/src/utils/shuffle.test.ts index 6e3ccd6..0539c5a 100644 --- a/src/utils/shuffle.test.ts +++ b/src/utils/shuffle.test.ts @@ -28,6 +28,20 @@ function seq(values: number[]): () => number { return () => values[i++ % values.length]; } +// Two calendars where the first (Julian) is used by *more* eras than the second +// (Persian) — to prove selection is per-calendar, not weighted by era count. +const twoCalendars: CalendarRegion[] = [ + { + region: { name: 'A', geo: square }, + eras: [ + { name: 'J1', start: '1600-01-01', end: '1650-01-01', calendar: 'Julian' }, + { name: 'J2', start: '1650-01-01', end: '1700-01-01', calendar: 'Julian' }, + { name: 'J3', start: '1700-01-01', end: '1750-01-01', calendar: 'Julian' }, + { name: 'P1', start: '1600-01-01', end: '1700-01-01', calendar: 'Persian' }, + ], + }, +]; + describe('pickRandomScene', () => { it('returns null when the dataset has only Gregorian (default) eras', () => { const onlyGregorian: CalendarRegion[] = [ @@ -41,6 +55,7 @@ describe('pickRandomScene', () => { expect(scene).not.toBeNull(); if (!scene) return; + expect(scene.calendar).toBe('Julian'); // The point is inside the region. expect(pointInPolygon(scene.lat, scene.lng, square)).toBe(true); @@ -50,6 +65,27 @@ describe('pickRandomScene', () => { expect(eras.map((e) => e.calendar)).not.toContain('Gregorian'); }); + it('weights each calendar equally, not by how many eras use it', () => { + // The first rng call selects the calendar from [Julian, Persian]. A low + // value picks the first, a high value the second — regardless of the fact + // that Julian has 3 eras and Persian only 1. + expect(pickRandomScene(twoCalendars, seq([0.1]))?.calendar).toBe('Julian'); + expect(pickRandomScene(twoCalendars, seq([0.9]))?.calendar).toBe('Persian'); + }); + + it('skips calendars shown recently', () => { + // With Julian "recent", only Persian is eligible, so any rng yields it. + expect(pickRandomScene(twoCalendars, seq([0.1]), ['Julian'])?.calendar).toBe('Persian'); + expect(pickRandomScene(twoCalendars, seq([0.9]), ['Julian'])?.calendar).toBe('Persian'); + }); + + it('falls back to the full set when every calendar is recent', () => { + // Both calendars recent → still returns one rather than null. + const scene = pickRandomScene(twoCalendars, seq([0.1]), ['Julian', 'Persian']); + expect(scene).not.toBeNull(); + expect(['Julian', 'Persian']).toContain(scene?.calendar); + }); + it('produces dates strictly before a bounded era end (half-open)', () => { // rng near 1 should still keep the day before the end boundary. const scene = pickRandomScene(regions, seq([0, 0.5, 0.5, 0.999999])); diff --git a/src/utils/shuffle.ts b/src/utils/shuffle.ts index fe6c1d4..73f8992 100644 --- a/src/utils/shuffle.ts +++ b/src/utils/shuffle.ts @@ -6,6 +6,14 @@ // region and a random date within one of that region's non-Gregorian eras, so // each press surfaces one of the historic reckonings (Islamic, Persian, Julian, // a lunar calendar) rather than the everyday Gregorian default. +// +// Two fairness rules (so the variety actually feels varied): +// 1. Each *calendar* is equally likely, regardless of how many regions/eras +// use it — otherwise a calendar shared by many regions (e.g. Julian) would +// dominate. We pick a calendar first, then a region/era within it. +// 2. Calendars shown recently are skipped (the caller passes the recent list), +// so consecutive presses don't repeat — unless every calendar is recent, in +// which case we fall back to the full set rather than return nothing. import type { CalendarRegion, Era, Geometry, LinearRing } from '../types'; import { pointInPolygon } from './geo'; @@ -17,6 +25,8 @@ export interface ShuffleScene { lat: number; lng: number; date: PickerDate; + /** The calendar this scene lands on (so the caller can avoid repeats). */ + calendar: string; } // Eras open at one end (null start / null end) still need a finite window to @@ -73,28 +83,43 @@ function randomDateInEra(era: Era, rng: () => number): PickerDate { } /** - * Pick a random scene that lands on a non-default (non-Gregorian) calendar: - * a random point inside some region and a random date within one of that - * region's non-Gregorian eras. Returns null if the dataset has no such era. + * Pick a random scene that lands on a non-default (non-Gregorian) calendar. + * Each distinct calendar is equally likely (not weighted by how many regions/ + * eras use it); calendars in `recent` are skipped so presses don't repeat, with + * a fallback to the full set when every calendar is recent. Returns null if the + * dataset has no non-Gregorian era at all. * * `rng` is injectable for deterministic tests; it defaults to Math.random. */ export function pickRandomScene( regions: CalendarRegion[], rng: () => number = Math.random, + recent: readonly string[] = [], ): ShuffleScene | null { - const candidates: { geo: Geometry; era: Era }[] = []; + // Group every non-Gregorian (region, era) candidate by its calendar, so we + // can give each calendar equal weight (rule 1). + const byCalendar = new Map<string, { geo: Geometry; era: Era }[]>(); for (const cr of regions) { for (const era of cr.eras) { - if (era.calendar !== 'Gregorian') { - candidates.push({ geo: cr.region.geo, era }); - } + if (era.calendar === 'Gregorian') continue; + const list = byCalendar.get(era.calendar) ?? []; + list.push({ geo: cr.region.geo, era }); + byCalendar.set(era.calendar, list); } } - if (candidates.length === 0) return null; + const calendars = [...byCalendar.keys()]; + if (calendars.length === 0) return null; + + // Prefer calendars not shown recently (rule 2); fall back to all of them if + // the recent window covers everything, so we always return a scene. + const recentSet = new Set(recent); + const eligible = calendars.filter((c) => !recentSet.has(c)); + const pool = eligible.length > 0 ? eligible : calendars; + const calendar = pool[Math.floor(rng() * pool.length)]; + const candidates = byCalendar.get(calendar)!; const { geo, era } = candidates[Math.floor(rng() * candidates.length)]; const { lat, lng } = randomPointInGeometry(geo, rng); const date = randomDateInEra(era, rng); - return { lat, lng, date }; + return { lat, lng, date, calendar }; }