Fixes: adv/dis as a global toggle, auto-number duplicate combatants

- Advantage/Disadvantage are now toggle modes (rollStore.mode) applied to d20
  rolls everywhere (dice page, sheet skills/saves/attacks), preserving modifiers;
  applyRollMode() rewrites the first d20 -> 2d20kh1/kl1
- addCombatant auto-numbers duplicate names (Goblin 1, Goblin 2, …); the first
  duplicate also renames the original. Quantity add relies on this.
- Tests for applyRollMode + duplicate numbering

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-08 01:50:02 +02:00
parent ed3d967526
commit d5977e4c63
8 changed files with 135 additions and 18 deletions
+1 -1
View File
@@ -202,7 +202,7 @@ function AddCombatantBar({
for (let i = 0; i < n; i++) { for (let i = 0; i < n; i++) {
onAdd({ onAdd({
id: newId(), id: newId(),
name: n > 1 ? `${name.trim()} ${i + 1}` : name.trim(), name: name.trim(), // engine auto-numbers duplicates
kind: 'monster', kind: 'monster',
initiative: init, initiative: init,
initBonus: 0, initBonus: 0,
+29 -6
View File
@@ -1,10 +1,11 @@
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import { useLiveQuery } from 'dexie-react-hooks'; import { useLiveQuery } from 'dexie-react-hooks';
import { rollDice, DiceParseError, type RollResult } from '@/lib/dice/notation'; import { rollDice, applyRollMode, DiceParseError, type RollResult } from '@/lib/dice/notation';
import { createRng } from '@/lib/rng'; import { createRng } from '@/lib/rng';
import { diceRepo } from '@/lib/db/repositories'; import { diceRepo } from '@/lib/db/repositories';
import { useUiStore } from '@/stores/uiStore'; import { useUiStore } from '@/stores/uiStore';
import { useMacroStore } from '@/stores/macroStore'; import { useMacroStore } from '@/stores/macroStore';
import { useRollStore } from '@/stores/rollStore';
import { Page, PageHeader } from '@/components/ui/Page'; import { Page, PageHeader } from '@/components/ui/Page';
import { Button } from '@/components/ui/Button'; import { Button } from '@/components/ui/Button';
import { Input } from '@/components/ui/Input'; import { Input } from '@/components/ui/Input';
@@ -26,16 +27,21 @@ export function DicePage() {
const addMacro = useMacroStore((s) => s.add); const addMacro = useMacroStore((s) => s.add);
const removeMacro = useMacroStore((s) => s.remove); const removeMacro = useMacroStore((s) => s.remove);
const mode = useRollStore((s) => s.mode);
const toggleMode = useRollStore((s) => s.toggleMode);
const roll = async (expression: string, label = '') => { const roll = async (expression: string, label = '') => {
try { try {
const result = rollDice(expression, createRng()); const finalExpr = applyRollMode(expression, mode);
const result = rollDice(finalExpr, createRng());
setLast(result); setLast(result);
setRollCount((n) => n + 1); setRollCount((n) => n + 1);
setError(null); setError(null);
const modeTag = mode === 'advantage' ? ' (adv)' : mode === 'disadvantage' ? ' (dis)' : '';
await diceRepo.add({ await diceRepo.add({
...(campaignId ? { campaignId } : {}), ...(campaignId ? { campaignId } : {}),
label, label: label + modeTag,
expression, expression: finalExpr,
total: result.total, total: result.total,
breakdown: result.breakdown, breakdown: result.breakdown,
}); });
@@ -81,13 +87,30 @@ export function DicePage() {
{d} {d}
</Button> </Button>
))} ))}
<Button size="sm" variant="secondary" onClick={() => void roll('2d20kh1', 'advantage')}> <Button
size="sm"
variant={mode === 'advantage' ? 'primary' : 'secondary'}
aria-pressed={mode === 'advantage'}
onClick={() => toggleMode('advantage')}
title="Toggle advantage for d20 rolls"
>
Advantage Advantage
</Button> </Button>
<Button size="sm" variant="secondary" onClick={() => void roll('2d20kl1', 'disadvantage')}> <Button
size="sm"
variant={mode === 'disadvantage' ? 'primary' : 'secondary'}
aria-pressed={mode === 'disadvantage'}
onClick={() => toggleMode('disadvantage')}
title="Toggle disadvantage for d20 rolls"
>
Disadvantage Disadvantage
</Button> </Button>
</div> </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'}.
</p>
)}
{/* Saved rolls (macros) */} {/* Saved rolls (macros) */}
<div className="mt-4"> <div className="mt-4">
+17
View File
@@ -89,6 +89,23 @@ describe('mutating mid-combat keeps the turn anchored (C16-C19 regressions)', ()
expect(e.round).toBe(2); expect(e.round).toBe(2);
}); });
it('auto-numbers duplicate names (Goblin -> Goblin 1, Goblin 2, …)', () => {
let e = encounter([]);
e = addCombatant(e, mk('g1', 10));
e = { ...e, combatants: e.combatants.map((c) => ({ ...c, name: 'Goblin' })) }; // first is "Goblin"
e = addCombatant(e, { ...mk('g2', 10), name: 'Goblin' });
expect(e.combatants.map((c) => c.name).sort()).toEqual(['Goblin 1', 'Goblin 2']);
e = addCombatant(e, { ...mk('g3', 10), name: 'Goblin' });
expect(e.combatants.find((c) => c.id === 'g3')?.name).toBe('Goblin 3');
});
it('does not number unique names', () => {
let e = encounter([]);
e = addCombatant(e, { ...mk('a', 10), name: 'Aragorn' });
e = addCombatant(e, { ...mk('b', 10), name: 'Legolas' });
expect(e.combatants.map((c) => c.name)).toEqual(['Aragorn', 'Legolas']);
});
it('adding a combatant mid-combat inserts by initiative without changing whose turn it is', () => { it('adding a combatant mid-combat inserts by initiative without changing whose turn it is', () => {
let e = startEncounter(encounter([mk('a', 30), mk('b', 10)])); let e = startEncounter(encounter([mk('a', 30), mk('b', 10)]));
e = nextTurn(e); // current = b e = nextTurn(e); // current = b
+39 -5
View File
@@ -106,17 +106,51 @@ export function previousTurn(enc: Encounter): Encounter {
}; };
} }
/** Strip a trailing " 3" style number so "Goblin 2" and "Goblin" share a base. */
function baseName(name: string): string {
return name.replace(/\s+\d+$/, '').trim();
}
/** /**
* Insert a combatant. When the encounter is active, the new combatant is placed * Disambiguate a new combatant's name against existing ones: the first duplicate
* by initiative and the turn pointer is corrected so it still points at the * renames the original to "Name 1" and the newcomer becomes "Name 2", etc.
* same combatant whose turn it is. * Returns the (possibly renamed) existing list and the new combatant's name.
*/
function disambiguate(existing: Combatant[], name: string): { combatants: Combatant[]; name: string } {
const base = baseName(name);
const sameBase = existing.filter((c) => baseName(c.name) === base);
if (sameBase.length === 0) return { combatants: existing, name };
let combatants = existing;
// If there's exactly one and it's unnumbered, number it "1" so the group reads 1,2,…
if (sameBase.length === 1 && !/\s+\d+$/.test(sameBase[0]!.name)) {
const id = sameBase[0]!.id;
combatants = existing.map((c) => (c.id === id ? { ...c, name: `${base} 1` } : c));
}
const maxNum = Math.max(
1,
...sameBase.map((c) => {
const m = /\s+(\d+)$/.exec(c.name);
return m ? Number(m[1]) : 0;
}),
);
return { combatants, name: `${base} ${maxNum + 1}` };
}
/**
* Insert a combatant. Auto-numbers duplicate names (Goblin 1, Goblin 2, …).
* When the encounter is active, the new combatant is placed by initiative and
* the turn pointer is corrected so it still points at whose turn it is.
*/ */
export function addCombatant(enc: Encounter, combatant: Combatant): Encounter { export function addCombatant(enc: Encounter, combatant: Combatant): Encounter {
const { combatants: renamed, name } = disambiguate(enc.combatants, combatant.name);
const incoming = name === combatant.name ? combatant : { ...combatant, name };
if (enc.status !== 'active') { if (enc.status !== 'active') {
return { ...enc, combatants: [...enc.combatants, combatant] }; return { ...enc, combatants: [...renamed, incoming] };
} }
const currentId = currentCombatant(enc)?.id; const currentId = currentCombatant(enc)?.id;
const combatants = sortByInitiative([...enc.combatants, combatant]); const combatants = sortByInitiative([...renamed, incoming]);
const turnIndex = Math.max(0, indexOfId(combatants, currentId)); const turnIndex = Math.max(0, indexOfId(combatants, currentId));
return { ...enc, combatants, turnIndex }; return { ...enc, combatants, turnIndex };
} }
+15 -1
View File
@@ -1,7 +1,21 @@
import { describe, it, expect } from 'vitest'; import { describe, it, expect } from 'vitest';
import { parseDice, rollDice, DiceParseError } from './notation'; import { parseDice, rollDice, applyRollMode, DiceParseError } from './notation';
import { createRng } from '@/lib/rng'; import { createRng } from '@/lib/rng';
describe('applyRollMode', () => {
it('turns a d20 into 2d20kh1 for advantage, keeping modifiers', () => {
expect(applyRollMode('1d20+5', 'advantage')).toBe('2d20kh1+5');
expect(applyRollMode('d20', 'disadvantage')).toBe('2d20kl1');
});
it('leaves non-d20 and normal rolls unchanged', () => {
expect(applyRollMode('2d6+3', 'advantage')).toBe('2d6+3');
expect(applyRollMode('1d20+5', 'normal')).toBe('1d20+5');
});
it('only transforms the first d20 term', () => {
expect(applyRollMode('1d20+1d20', 'advantage')).toBe('2d20kh1+1d20');
});
});
describe('parseDice', () => { describe('parseDice', () => {
it('parses a simple die group', () => { it('parses a simple die group', () => {
expect(parseDice('3d6')).toEqual([{ kind: 'dice', sign: 1, count: 3, sides: 6 }]); expect(parseDice('3d6')).toEqual([{ kind: 'dice', sign: 1, count: 3, sides: 6 }]);
+18
View File
@@ -48,6 +48,24 @@ export interface RollResult {
export class DiceParseError extends Error {} 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).
*/
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}`;
});
}
const MAX_DICE = 1000; const MAX_DICE = 1000;
const MAX_SIDES = 1000; const MAX_SIDES = 1000;
+6 -3
View File
@@ -1,4 +1,4 @@
import { rollDice } from '@/lib/dice/notation'; import { rollDice, applyRollMode } from '@/lib/dice/notation';
import { createRng } from '@/lib/rng'; import { createRng } from '@/lib/rng';
import { degreeOfSuccess, type Degree } from '@/lib/dice/check'; import { degreeOfSuccess, type Degree } from '@/lib/dice/check';
import { diceRepo } from '@/lib/db/repositories'; import { diceRepo } from '@/lib/db/repositories';
@@ -30,9 +30,12 @@ function naturalD20(result: ReturnType<typeof rollDice>): number | undefined {
* Returns the result + degree. Used by the dice page, sheets, and statblocks. * Returns the result + degree. Used by the dice page, sheets, and statblocks.
*/ */
export function rollAndShow(opts: RollOptions): { total: number; degree?: Degree } { export function rollAndShow(opts: RollOptions): { total: number; degree?: Degree } {
const result = rollDice(opts.expression, createRng()); const mode = useRollStore.getState().mode;
const expression = applyRollMode(opts.expression, mode);
const result = rollDice(expression, createRng());
const campaignId = useUiStore.getState().activeCampaignId ?? undefined; const campaignId = useUiStore.getState().activeCampaignId ?? undefined;
const label = opts.label ?? ''; const modeTag = mode === 'advantage' ? ' (adv)' : mode === 'disadvantage' ? ' (dis)' : '';
const label = (opts.label ?? '') + modeTag;
let degree: Degree | undefined; let degree: Degree | undefined;
if (opts.dc !== undefined && opts.system) { if (opts.dc !== undefined && opts.system) {
+10 -2
View File
@@ -1,5 +1,5 @@
import { create } from 'zustand'; import { create } from 'zustand';
import type { RollResult } from '@/lib/dice/notation'; import type { RollResult, RollMode } from '@/lib/dice/notation';
import type { Degree } from '@/lib/dice/check'; import type { Degree } from '@/lib/dice/check';
export interface TrayRoll { export interface TrayRoll {
@@ -13,13 +13,21 @@ export interface TrayRoll {
interface RollState { interface RollState {
last: TrayRoll | null; last: TrayRoll | null;
/** advantage/disadvantage toggle applied to d20 rolls everywhere */
mode: RollMode;
push: (roll: Omit<TrayRoll, 'seq'>) => void; push: (roll: Omit<TrayRoll, 'seq'>) => void;
dismiss: () => void; dismiss: () => void;
setMode: (mode: RollMode) => void;
/** click the same mode again to turn it off */
toggleMode: (mode: Exclude<RollMode, 'normal'>) => void;
} }
let seq = 0; let seq = 0;
export const useRollStore = create<RollState>((set) => ({ export const useRollStore = create<RollState>((set, get) => ({
last: null, last: null,
mode: 'normal',
push: (roll) => set({ last: { ...roll, seq: ++seq } }), push: (roll) => set({ last: { ...roll, seq: ++seq } }),
dismiss: () => set({ last: null }), dismiss: () => set({ last: null }),
setMode: (mode) => set({ mode }),
toggleMode: (mode) => set({ mode: get().mode === mode ? 'normal' : mode }),
})); }));