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
+17
View File
@@ -89,6 +89,23 @@ describe('mutating mid-combat keeps the turn anchored (C16-C19 regressions)', ()
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', () => {
let e = startEncounter(encounter([mk('a', 30), mk('b', 10)]));
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
* by initiative and the turn pointer is corrected so it still points at the
* same combatant whose turn it is.
* Disambiguate a new combatant's name against existing ones: the first duplicate
* renames the original to "Name 1" and the newcomer becomes "Name 2", etc.
* 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 {
const { combatants: renamed, name } = disambiguate(enc.combatants, combatant.name);
const incoming = name === combatant.name ? combatant : { ...combatant, name };
if (enc.status !== 'active') {
return { ...enc, combatants: [...enc.combatants, combatant] };
return { ...enc, combatants: [...renamed, incoming] };
}
const currentId = currentCombatant(enc)?.id;
const combatants = sortByInitiative([...enc.combatants, combatant]);
const combatants = sortByInitiative([...renamed, incoming]);
const turnIndex = Math.max(0, indexOfId(combatants, currentId));
return { ...enc, combatants, turnIndex };
}
+15 -1
View File
@@ -1,7 +1,21 @@
import { describe, it, expect } from 'vitest';
import { parseDice, rollDice, DiceParseError } from './notation';
import { parseDice, rollDice, applyRollMode, DiceParseError } from './notation';
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', () => {
it('parses a simple die group', () => {
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 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_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 { degreeOfSuccess, type Degree } from '@/lib/dice/check';
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.
*/
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 label = opts.label ?? '';
const modeTag = mode === 'advantage' ? ' (adv)' : mode === 'disadvantage' ? ' (dis)' : '';
const label = (opts.label ?? '') + modeTag;
let degree: Degree | undefined;
if (opts.dc !== undefined && opts.system) {