diff --git a/e2e/interactive-dice.spec.ts b/e2e/interactive-dice.spec.ts index 815cfe7..1b780ee 100644 --- a/e2e/interactive-dice.spec.ts +++ b/e2e/interactive-dice.spec.ts @@ -24,6 +24,14 @@ test('rolling a skill from the sheet shows the roll tray', async ({ page }) => { await expect(page.locator('text=/= \\d+/').first()).toBeVisible(); }); +test('advantage toggle applies to any die, not just d20', async ({ page }) => { + await page.getByRole('link', { name: 'Dice' }).click(); + await page.getByRole('button', { name: 'Advantage', exact: true }).click(); + await page.getByRole('button', { name: 'd4', exact: true }).click(); + // The rolled expression became 2d4kh1 (advantage on a d4) + await expect(page.getByText(/Rolled 2d4kh1/)).toBeVisible(); +}); + test('dice page: save and use a macro', async ({ page }) => { await page.getByRole('link', { name: 'Dice' }).click(); await page.getByLabel('Dice expression').fill('2d6+3'); diff --git a/src/features/dice/DicePage.tsx b/src/features/dice/DicePage.tsx index 98d255f..f2bc3e1 100644 --- a/src/features/dice/DicePage.tsx +++ b/src/features/dice/DicePage.tsx @@ -92,7 +92,7 @@ export function DicePage() { variant={mode === 'advantage' ? 'primary' : 'secondary'} aria-pressed={mode === 'advantage'} onClick={() => toggleMode('advantage')} - title="Toggle advantage for d20 rolls" + title="Toggle advantage — rolls the next die twice, keep highest" > Advantage @@ -101,14 +101,14 @@ export function DicePage() { variant={mode === 'disadvantage' ? 'primary' : 'secondary'} aria-pressed={mode === 'disadvantage'} onClick={() => toggleMode('disadvantage')} - title="Toggle disadvantage for d20 rolls" + title="Toggle disadvantage — rolls the next die twice, keep lowest" > Disadvantage {mode !== 'normal' && (

- {mode === 'advantage' ? 'Advantage' : 'Disadvantage'} is on — d20 rolls use 2d20{mode === 'advantage' ? 'kh1' : 'kl1'}. + {mode === 'advantage' ? 'Advantage' : 'Disadvantage'} is on — the next single die rolls twice, keeping the {mode === 'advantage' ? 'highest' : 'lowest'}.

)} diff --git a/src/lib/dice/notation.test.ts b/src/lib/dice/notation.test.ts index a6b9d32..a8c66b3 100644 --- a/src/lib/dice/notation.test.ts +++ b/src/lib/dice/notation.test.ts @@ -11,9 +11,18 @@ describe('applyRollMode', () => { expect(applyRollMode('2d6+3', 'advantage')).toBe('2d6+3'); expect(applyRollMode('1d20+5', 'normal')).toBe('1d20+5'); }); - it('only transforms the first d20 term', () => { + it('only transforms the first die term', () => { expect(applyRollMode('1d20+1d20', 'advantage')).toBe('2d20kh1+1d20'); }); + it('applies to any single die, not just d20', () => { + expect(applyRollMode('1d4', 'advantage')).toBe('2d4kh1'); + expect(applyRollMode('d6', 'disadvantage')).toBe('2d6kl1'); + expect(applyRollMode('1d8+3', 'advantage')).toBe('2d8kh1+3'); + }); + it('does not touch multi-die pools or terms that already keep/drop', () => { + expect(applyRollMode('2d6+3', 'advantage')).toBe('2d6+3'); + expect(applyRollMode('4d6kh3', 'advantage')).toBe('4d6kh3'); + }); }); describe('parseDice', () => { diff --git a/src/lib/dice/notation.ts b/src/lib/dice/notation.ts index 09e455b..681089f 100644 --- a/src/lib/dice/notation.ts +++ b/src/lib/dice/notation.ts @@ -51,18 +51,21 @@ export class DiceParseError extends Error {} export type RollMode = 'normal' | 'advantage' | 'disadvantage'; /** - * Apply advantage/disadvantage to a d20 expression by turning the first d20 term - * into 2d20kh1 / 2d20kl1. Non-d20 expressions are returned unchanged, so the - * toggle only affects d20 checks (and keeps any modifiers, e.g. 1d20+5). + * Apply advantage/disadvantage by turning the first single-die term (any dN — + * d4, d6, d20, …) into 2dN kh1 / 2dN kl1, keeping any modifiers (e.g. 1d20+5). + * Only single-die terms (count 1 or omitted) are affected, since "advantage" on + * a pool like 2d6 isn't well-defined; terms that already carry a keep/drop + * modifier (e.g. 4d6kh3) are left untouched so we never double-apply. */ export function applyRollMode(expression: string, mode: RollMode): string { if (mode === 'normal') return expression; const keep = mode === 'advantage' ? 'kh1' : 'kl1'; - let replaced = false; - return expression.replace(/(^|[+\-\s])\d*d20\b/i, (m, pre: string) => { - if (replaced) return m; - replaced = true; - return `${pre}2d20${keep}`; + let done = false; + return expression.replace(/(^|[+\-\s])(\d*)d(\d+)((?:kh|kl|dh|dl)\d+)?/gi, (m, pre: string, count: string, sides: string, mod: string | undefined) => { + if (done || mod) return m; + if (count !== '' && count !== '1') return m; // only single-die terms + done = true; + return `${pre}2d${sides}${keep}`; }); }