diff --git a/apps/api/src/hunting/hunting.module.ts b/apps/api/src/hunting/hunting.module.ts index e963735..2ae552b 100644 --- a/apps/api/src/hunting/hunting.module.ts +++ b/apps/api/src/hunting/hunting.module.ts @@ -8,7 +8,7 @@ import { Hunt } from './entities/hunt.entity'; import { HuntEncounter } from './entities/hunt-encounter.entity'; import { HuntingController } from './hunting.controller'; import { HuntingService } from './hunting.service'; -import { RANDOM_SOURCE, systemRandomSource } from './random-source'; +import { RANDOM_SOURCE, systemRandomSource } from '../shared/random-source'; @Module({ imports: [ diff --git a/apps/api/src/hunting/hunting.service.spec.ts b/apps/api/src/hunting/hunting.service.spec.ts index 7d9783c..21138d9 100644 --- a/apps/api/src/hunting/hunting.service.spec.ts +++ b/apps/api/src/hunting/hunting.service.spec.ts @@ -12,7 +12,7 @@ import { HuntEncounter } from './entities/hunt-encounter.entity'; import { HuntStatus } from './hunt-status.enum'; import { HuntingDomainError } from './hunting.errors'; import { HuntingService } from './hunting.service'; -import type { RandomSource } from './random-source'; +import type { RandomSource } from '../shared/random-source'; const CHARACTER_ID = '10000000-0000-4000-8000-000000000001'; const HUNTING_LOCATION_ID = '20000000-0000-4000-8000-000000000001'; diff --git a/apps/api/src/hunting/hunting.service.ts b/apps/api/src/hunting/hunting.service.ts index 55e71b7..f70c82d 100644 --- a/apps/api/src/hunting/hunting.service.ts +++ b/apps/api/src/hunting/hunting.service.ts @@ -16,8 +16,8 @@ import { huntingNotAvailable, noHuntEncountersAvailable, } from './hunting.errors'; -import { RANDOM_SOURCE } from './random-source'; -import type { RandomSource } from './random-source'; +import { RANDOM_SOURCE } from '../shared/random-source'; +import type { RandomSource } from '../shared/random-source'; export interface MonsterSummary { key: string; diff --git a/apps/api/src/hunting/random-source.ts b/apps/api/src/shared/random-source.ts similarity index 100% rename from apps/api/src/hunting/random-source.ts rename to apps/api/src/shared/random-source.ts diff --git a/apps/api/src/shared/roll-range.spec.ts b/apps/api/src/shared/roll-range.spec.ts new file mode 100644 index 0000000..17389cf --- /dev/null +++ b/apps/api/src/shared/roll-range.spec.ts @@ -0,0 +1,28 @@ +import type { RandomSource } from './random-source'; +import { rollInclusive } from './roll-range'; + +function fixed(...values: number[]): RandomSource { + let index = 0; + return { next: () => values[index++] }; +} + +describe('rollInclusive', () => { + it('maps the bottom of the random range to min and the top to max', () => { + expect(rollInclusive(fixed(0), 4, 7)).toBe(4); + expect(rollInclusive(fixed(0.999), 4, 7)).toBe(7); + }); + + it('spreads the random range evenly across every value in between', () => { + expect(rollInclusive(fixed(0.25), 4, 7)).toBe(5); + expect(rollInclusive(fixed(0.5), 4, 7)).toBe(6); + expect(rollInclusive(fixed(0.5), 9, 15)).toBe(12); + }); + + it('never exceeds max even if the source yields exactly 1', () => { + expect(rollInclusive(fixed(1), 9, 15)).toBe(15); + }); + + it('returns the single value when min equals max', () => { + expect(rollInclusive(fixed(0.7), 1, 1)).toBe(1); + }); +}); diff --git a/apps/api/src/shared/roll-range.ts b/apps/api/src/shared/roll-range.ts new file mode 100644 index 0000000..1c2c97a --- /dev/null +++ b/apps/api/src/shared/roll-range.ts @@ -0,0 +1,19 @@ +import type { RandomSource } from './random-source'; + +/** + * Rolls an inclusive integer in [min, max] from one value of `random`. + * + * `RandomSource.next()` is documented as [0, 1), but the clamp keeps a + * misbehaving or hand-stubbed source from ever exceeding `max`. + */ +export function rollInclusive( + random: RandomSource, + min: number, + max: number, +): number { + if (max <= min) { + return min; + } + + return Math.min(max, min + Math.floor(random.next() * (max - min + 1))); +}