Phase 16: advantage/disadvantage applies to any single die

applyRollMode (src/lib/dice/notation.ts) now rewrites the first single-die term
of any size (d4/d6/d20/...) to 2dN kh1/kl1, not just d20. Multi-die pools (2d6)
and terms with an existing keep/drop (4d6kh3) are left untouched. Dice page copy
updated; unit tests for d4/d6/d8 + skip cases; e2e toggles Advantage and rolls a
d4 → 2d4kh1.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-08 10:18:57 +02:00
parent 04fa90e580
commit ffe24495b6
4 changed files with 32 additions and 12 deletions
+3 -3
View File
@@ -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
</Button>
@@ -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
</Button>
</div>
{mode !== 'normal' && (
<p className="mt-2 text-xs text-accent">
{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'}.
</p>
)}
+10 -1
View File
@@ -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', () => {
+11 -8
View File
@@ -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}`;
});
}